src/PSGopher.psm1

<#
PSGopher -- a PowerShell client for Gopher and Gopher+ servers.
Copyright (C) 2021-2023 Colin Cogle <colin@colincogle.name>

This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>.
#>


#Requires -Version 7.1
Function Invoke-GopherRequest {
    [CmdletBinding(DefaultParameterSetName='ToScreen')]
    [OutputType([PSCustomObject], ParameterSetName='ToScreen')]
    [OutputType([Void], ParameterSetName='OutFile')]
    [Alias('igr')]
    Param(
        [Parameter(Mandatory, Position=0)]
        [Alias('Url')]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^(gophers?|sgopher|gopher\+tls):\/\/')]
        [Uri] $Uri,

        [Alias('UseTLS', 'RequireTLS', 'RequireSSL')]
        [Switch] $UseSSL,

        [Alias('TryTLS', 'OpportunisticTLS', 'OpportunisticSSL')]
        [Switch] $TrySSL,

        [Alias('Abstract','Admin','Attributes','Information')]
        [Switch] $Info,

        [ValidatePattern("[a-z]+\/.+")]
        [AllowNull()]
        [String[]] $Views,

        [ValidateSet('ASCII','UTF7','UTF8','UTF16','Unicode','UTF32')]
        [String] $Encoding = 'UTF8',

        [Parameter(ParameterSetName='OutFile')]
        [ValidateNotNullOrEmpty()]
        [String] $OutFile,

        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Post','PostData','Query','QueryString')]
        [AllowNull()]
        [String] $InputObject
    )

    #region Establish TCP connection.
    # If we have a secure URL scheme, set UseSSL to true.
    $UseSSL = $UseSSL -or ($Uri.Scheme -In @('gophers','sgopher','gopher+tls'))

    # Sometimes, the .NET runtime doesn't recognize which port we're supposed
    # to be using -- especially if we use a Gopher-TLS scheme for a secure
    # connection. If so, we need to make a new URL with the port defined.
    If ($Uri.Port -eq -1) {
        $Path, $Query = $Uri.PathAndQuery -Split '\?',2
        $Uri = [Uri]::new("$($Uri.Scheme)://$($Uri.Host):70$Path$($Query ? "?$Query" : '')")
        # New URL = ___
        Write-Debug (Get-MessageTranslation 1 $Uri)
    }

    If ($UseSSL -ne $true) {
        # "Connecting to ___."
        Write-Verbose (Get-MessageTranslation 2 $Uri.Host)
    }
    Else {
        # "Connecting to ___ securely."
        Write-Verbose (Get-MessageTranslation 3 $Uri.Host)
    }

    Try {
        $TcpSocket = [Net.Sockets.TcpClient]::new($Uri.Host, $Uri.Port ?? 70)
        $TcpStream = $TcpSocket.GetStream()
        $TcpStream.ReadTimeout = 2000 #milliseconds
        If ($UseSSL -or $TrySSL) {
            # "Upgrading connection to TLS."
            Write-Debug (Get-MessageTranslation 4)
            $secureStream = [Net.Security.SslStream]::new($TcpStream, $false)
            $secureStream.AuthenticateAsClient($Uri.Host)
            $TcpStream = $secureStream
            # Connected with <TLS version> using <cipher/ciphersuite>".
            Write-Debug (Get-MessageTranslation 5 @($TcpStream.SslProtocol, $TcpStream.NegotiatedCipherSuite))
        }
    }
    Catch {
        # If we're using -TrySSL, then we'll retry without encryption. Else,s
        # If we're using -UseSSL, then fail the connection.
        If ($UseSSL) {
            # Throw a non-terminating error so that $? is set properly and the
            # pipeline can continue. This will allow chaining operators to work as
            # intended. Should a future version of this module support pipeline
            # input, that will let this cmdlet keep running with other input URIs.
            $er = [Management.Automation.ErrorRecord]::new(
                # "Could not connect to {host}:{port} with SSL/TLS. Aborting."
                [Net.WebException]::new((Get-MessageTranslation 7 @($Uri.Host, $Uri.Port ?? 70))),
                'TlsConnectionFailed',
                [Management.Automation.ErrorCategory]::ConnectionError,
                $Uri
            )
            $er.CategoryInfo.Activity = 'NegotiateTlsConnection'
            $PSCmdlet.WriteError($er)
            Return $null
        }
        ElseIf ($TrySSL) {
            # "Could not connect to {host}:{port} with SSL/TLS. Retrying with a non-secured connection."
            Write-Verbose (Get-MessageTranslation 8 @($Uri.Host, $Uri.Port ?? 70))
            $NewParameters = @{
                'Uri' = $Uri
                'Info' = $Info
                'Views' = $Views ?? @()    # not sure why this is needed
                'Encoding' = $Encoding
                'InputObject' = $InputObject
                'TrySSL' = $null
                'UseSSL' = $null
            }

            If ($PSCmdlet.ParameterSetName -eq 'OutFile') {
                $NewParameters.'OutFile' = $OutFile
            }

            Remove-Variable -Name 'SecureStream' -Force
            Return (Invoke-GopherRequest @NewParameters)
            Exit
        }
        Else {
            # "Could not connect to {host}:{port}. Aborting."
            Write-Error (Get-MessageTranslation 6 @($Uri.Host, $Uri.Port ?? 70))
            Return $null
        }
    }
    #endregion (Establish TCP connection)


    #region Content type negotiation
    $ContentTypeExpected = $null

    # If the user provided one, we'll use that.
    # But it needs to be removed from the URI.
    If ($Uri.PathAndQuery -CMatch "^\/[0123456789+gIT:;<dhis]") {
        $ContentTypeExpected = $Uri.PathAndQuery[1]

        # The code may have removed the leading slash. Put that back.
        # If there was already one, remove it.
        $Path = $Uri.PathAndQuery.Substring(2)
        $Path = "/$Path" -Replace '//','/'

        Write-Debug (Get-MessageTranslation 9 @($Uri.PathAndQuery, $Path))

        $Uri = [Uri]::new("$($Uri.Scheme)://$($Uri.Host):$($Uri.Port)$Path")
    }

    # Otherwise, let's try and guess -- if we have a file extension.
    ElseIf ($Uri.AbsolutePath -Match '\.') {
        $ContentTypeExpected = (Get-GopherType ($Uri.AbsolutePath -Split '\.')[-1] -Verbose:$VerbosePreference -Debug:$DebugPreference)
    }

    # If we still can't figure it out after all this, assume it's a Gopher menu.
    $ContentTypeExpected ??= '1'

    # Determine if we're reading a binary file or text.
    $BINARY_TRANSFER = (-Not $Info) -and ($ContentTypeExpected -In @('4','5','9','g','I',':',';','<','d','s') )
    #endregion (Content type negotiation)

    #region Parse input parameters
    If ($null -eq $InputObject -or $InputObject.Length -eq 0) {
        # "No additional query string detected"
        Write-Debug (Get-MessageTranslation 10)
    }
    Else {
        # "Found additional query string=___"
        Write-Debug (Get-MessageTranslation 11 $InputObject)

        $Encoder = [Web.HttpUtility]::ParseQueryString('')
        $Encoder.Add($null, $InputObject)
        $EncodedInput = $Encoder.ToString() -Replace '\+','%20'    # Gopher requires URL (percent) encoding for spaces.

        # "Encoded additional query string=___"
        Write-Debug (Get-MessageTranslation 12 $EncodedInput)

        # If there was already a query string specified in the URL, we will send
        # both of them, with the URL taking precedence.
        If ($Uri.Query) {
            # "Found existing query string=___"
            Write-Debug (Get-MessageTranslation 13 $Uri.Query)
        }
        $Uri = [Uri]::new($Uri.ToString() + ($Uri.Query ? '&' : '?') + $EncodedInput)
    }
    #endregion

    #region Send request
    $ToSend = $Uri.PathAndQuery
    If ($Info) {
        If ($ContentTypeExpected -eq '1') {
            $ToSend += "`t$"
        }
        Else {
            $ToSend += "`t!"
        }
    }
    If ($Views) {
        $ToSend += "`t+$Views"
    }
    $ToSend += "`r`n"
    # "Sending ___ bytes to server: ___"
    Write-Debug (Get-MessageTranslation 14 @($ToSend.Length, ($ToSend -Replace "`r",'\r' -Replace "`n",'\n' -Replace "`t",'\t')))
    $writer = [IO.StreamWriter]::new($TcpStream)
    $writer.WriteLine($ToSend)
    $writer.Flush()
    #endregion (Send request)

    #region Receive data
    # Set text encoding for reading and writing textual output.
    If (-Not $BINARY_TRANSFER) {
        Switch ($Encoding) {
            'ASCII'   {$Encoder = [Text.AsciiEncoding]::new()}
            'UTF7'    {$Encoder = [Text.UTF7Encoding]::new()}
            'UTF8'    {$Encoder = [Text.UTF8Encoding]::new()}
            'UTF16'   {$Encoder = [Text.UnicodeEncoding]::new()}
            'Unicode' {$Encoder = [Text.UnicodeEncoding]::new()}
            'UTF32'   {$Encoder = [Text.UTF32Encoding]::new()}
            default   {Throw [NotImplementedException]::new((Get-MessageTranslation 15))}
        }
    }

    # Read the full response.
    $response = ($BINARY_TRANSFER ? [IO.MemoryStream]::new() : '')
    $BufferSize = 102400     # 100 KB, more than enough for text, but a sizable
                            # buffer to make binary transfers fast.
    $buffer = New-Object Byte[] $BufferSize

    If (-Not $BINARY_TRANSFER)
    {
        If ($Info) {
            # "Beginning to read (attributes)"
            Write-Debug (Get-MessageTranslation 16)
        } Else {
            # "Beginning to read (textual type ___)"
            Write-Debug (Get-MessageTranslation 17 $ContentTypeExpected)
        }

        While (0 -ne ($bytesRead = $TcpStream.Read($buffer, 0, $BufferSize))) {
            # <TAB> "Reading ≤___ bytes from the server."
            Write-Debug "`t$(Get-MessageTranslation 18 $BufferSize)"
            $response += $Encoder.GetString($buffer, 0, $bytesRead)
        }
        # "Received ___ bytes from server."
        Write-Verbose (Get-MessageTranslation 19 $Encoder.GetByteCount($response))
    }
    Else # it is a binary transfer #
    {
        # "Beginning to read (binary type ___)."
        Write-Debug (Get-MessageTranslation 20 $ContentTypeExpected)
        While (0 -ne ($bytesRead = $TcpStream.Read($buffer, 0, $BufferSize))) {
            # <TAB> "Reading ≤___ bytes from the server."
            Write-Debug "`t$(Get-MessageTranslation 18 $BufferSize)"
            Write-Debug "`tGot $bytesRead bytes"
            $response.Write($buffer, 0, $bytesRead)
        }
        $response.Flush()
        # "Received ___ bytes from server."
        Write-Verbose (Get-MessageTranslation 19 $response.Length)
    }
    #endregion (Receive data)

    # Close connections.
    Write-Debug (Get-MessageTranslation 21)
    $writer.Close()
    $TcpSocket.Close()

    #region Parse response
    $Content = ''
    $Links = @()

    # Check for errors. All errors begin with '3'.
    If ( `
        ($BINARY_TRANSFER -and $response.ToArray()[0] -eq 51) -or `
        (-Not $BINARY_TRANSFER -and $response[0] -eq '3' -and $response -CLike '*error.host*') `
    ) {
        If ($BINARY_TRANSFER) {
            $response = [Text.Encoding]::ASCII.GetString($response.ToArray())
        }
        Write-Error -Message ($response.Substring(1, $response.IndexOf("`t"))) -TargetObject $Uri -ErrorId 3 -Category 'ResourceUnavailable'
        Return $null
    }
    # If this is not a Gopher menu, then simply return the raw output.
    ElseIf ($BINARY_TRANSFER) {
        If (-Not $Views) {
            $Content = $response.ToArray()
        }
        Else {
            # A Views query will include a plus sign, the file size, and then a
            # \r\n. Remove that header from the Content.
            $arr = $response.ToArray()
            $dataStarts = 0
            If ($arr[0] -eq 43) <# a plus sign #>
            {
                For ($i = 0; $i -lt $arr.Length; $i++) {
                    If ($arr[$i] -eq 13 -and $arr[$i + 1] -eq 10) {
                        $dataStarts = $i + 2
                        Break
                    }
                }
            }

            $Content = $arr[$dataStarts..($arr.Length)]
        }
    }
    # If this is anything non-binary and not a menu, simply return it.
    ElseIf ($ContentTypeExpected -ne '1') {
        If (-Not $Views) {
            $Content = $response
        }
        Else {
            $Content = ($response -Split "`r`n",2)[1]
        }
    }
    Else {
        $response -Split "(`r`n)" | ForEach-Object {
            # Show the output
            Write-Debug (Get-MessageTranslation 22 ($_ -Replace "`r",'' -Replace "`n",''))

            # Build Content variable
            If ($_.Length -gt 0) {
                $Content += ($_.Substring(1) -Split "`t")[0]
            }
            Else {
                $Content += "`r`n"
            }

            # Look for links or errors. However, we can skip this if we're using
            # the -OutFile or -Info parameters, because no link objects are returned.
            If (-Not $OutFile -and -Not $Info  -and $_ -Match "`t") {
                $line = $_
                Switch -CaseSensitive -RegEx ($_[0]) {
                    'i' {
                        Break
                    }

                    default {
                        $result = Convert-GopherLink $line -Server $Uri.Host -Port $Uri.Port -Verbose:$VerbosePreference -Debug:$DebugPreference
                        $Links += $result
                    }
                }
            }
        }
    }
    #endregion (Parse response)

    #region Generate output
    # If we are saving the output to a file, then we do not send anything to the
    # output buffer. We save the Content to a file instead.
    If ($OutFile) {
        # Don't write output if an error occurred.
        If ($response[0] -eq '3') {
            Write-Error $Content
            Return $null
        } Else {
            If (-Not $BINARY_TRANSFER)
            {
                # "Writing # bytes to <filename>"
                Write-Verbose (Get-MessageTranslation 23 @($Encoder.GetByteCount($response), $OutFile))
                Set-Content -Path $OutFile -Value $Content -Encoding $Encoding -NoNewline
            }
            Else {
                # "Writing # bytes to <filename>"
                Write-Verbose (Get-MessageTranslation 23 @($response.Length, $OutFile))
                Set-Content -Path $OutFile -Value $Content -AsByteStream
            }
        }
        Return
    }
    # TODO: figure out how to parse Gophermaps in Gopher+ mode.
    # For now, let's skip all this and return it as plain text.
    ElseIf ($Info -and $ContentTypeExpected -ne '1') {
        $Result = [PSCustomObject]@{}

        # For each line of Gopher+ output, we're going to see if it begins with
        # a plus sign. If so, we have an attribute name. Then, we're going to
        # go through each line of output and save that. Once we find another
        # attribute name, add the two items to $Result.
        $AttributeName = ''
        $AttributeValue = ''
        $response -Split "(\+[A-Z]+):" | ForEach-Object {
            If ($_.Length -gt 0) {
                # "Gopher+ output line: ___"
                Write-Debug (Get-MessageTranslation 24 $_)
                # If we've found an attribute, then add the current name/value
                # into $Result (if there is one).
                If ($_[0] -eq '+') {
                    If ($AttributeValue) {
                        If ($AttributeName -In @('ADMIN', 'VIEWS')) {
                            $splits = $AttributeValue.Split("`r`n", [StringSplitOptions]::RemoveEmptyEntries).Trim()
                            $Result | Add-Member -NotePropertyName $AttributeName -NotePropertyValue $splits
                            # ($AttributeValue -Split "\s*\r\n\s*")
                        }
                        Else {
                            $Result | Add-Member -NotePropertyName $AttributeName -NotePropertyValue $AttributeValue.Trim()
                        }
                    }

                    # Now, get ready for the next attribute.
                    $AttributeName  = $_.Substring(1).Trim()
                    $AttributeValue = ''
                }
                # This is not an attribute name, so add it to our
                # currently-saved value.
                Else {
                    $AttributeValue += $_
                }
            }
        }

        # What's left in $AttributeValue must be an attribute.
        # This is a repeat of the above few lines of code. If anyone can
        # refactor this into something better, please do!
        If ($AttributeName -In @('ADMIN', 'VIEWS')) {
            $Result | Add-Member -NotePropertyName $AttributeName -NotePropertyValue $AttributeValue.Split("\s*(\r\n)+\s*", [StringSplitOptions]::RemoveEmptyEntries)
        }
        Else {
            $Result | Add-Member -NotePropertyName $AttributeName -NotePropertyValue $AttributeValue.Trim()
        }
        Return $Result
    }
    Else {
        # Let's tell the user how we fetched this resource/attributes.
        # This will be more useful when I implement opportunistic TLS.
        $Protocol = 'Gopher'
        If ($Info -or $Views) {
            $Protocol = 'Gopher+'
        }
        If ((Test-Path 'variable:\SecureStream') -and ($null -ne $SecureStream)) {
            $Protocol = "Secure$Protocol"
        }

        Return [PSCustomObject]@{
            'Protocol' = $Protocol
            'ContentType' = $ContentTypeExpected ?? '1'
            'Content' = $Content
            'Encoding' = ($BINARY_TRANSFER ? $Content.GetType() : $Encoder.GetType())
            'Images'  = $Links | Where-Object Type -In @('g','I')
            'Links' = $Links
            'RawContent'  = ($BINARY_TRANSFER ? $response.ToArray() : $response)
            'RawContentLength' = $response.Length
        }
    }
    #endregion (Generate output)
}

Function Convert-GopherLink {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param(
        [Parameter(Mandatory, Position=0)]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^(?:.)(?:[^\t]*)\t')]
        [String] $InputObject,

        [String] $Server,

        [UInt16] $Port
    )

    # "*** Found a Gopher link."
    Write-Debug "*** $(Get-MessageTranslation 25)"
    $fields = $InputObject -Split "`t"
    $Uri    = $null

    # Are we dealing with a /URL: link? If so, we can easily create
    # the href [Uri]. Otherwise, we'll need to build it ourselves.
    If ($fields[1] -CLike 'URL:*' -or $fields[1] -CLike '/URL:*') {
        $Uri = [Uri]::new($fields[1] -Replace [RegEx]"\/?URL:",'')
    }
    Else {
        $Server = ${fields}?[2] ?? $Server
        $Port   = ${fields}?[3] ?? $Port

        # Pick the appropriate URL schema for the link type.
        # For the first two (CCSO and Telnet), there should be nothing after the
        # optional port, but let's include it anyway.
        Switch -CaseSensitive -RegEx ($fields[0][0]) {
            '2'     {$Port ??= 105; $Uri = [Uri]::new("cso://${Server}:$Port/$($fields[1])")}
            '[8T]'  {$Port ??= 23;  $Uri = [Uri]::new("telnet://${Server}:$Port/$($fields[1])")}
            default {$Port ??= 70;  $Uri = [Uri]::new("gopher://${Server}:$Port/$($fields[1])")}
        }
    }

    # "*** Type=_: <URL>" and "LINK: Type=_: <URL>", respectively.
    Write-Debug   "*** $(Get-MessageTranslation 26 @($fields[0][0], $Uri))"
    Write-Verbose "*** $(Get-MessageTranslation 27 @($fields[0][0], $Uri))"
    Return [PSCustomObject]@{
        'href' = $uri
        'Type' = $fields[0][0]
        'Description' = $fields[0].Substring(1)
        'Resource' = $Uri.AbsolutePath
        'Server' = $Uri.Host
        'Port' = $Uri.Port
        'UrlLink' = ($InputObject -Match '\t\/?URL:')
    }
}

# This helper function guessed at types when the user forgets to enter one.
# This ensures that data will be returned in either text or binary format.
# Feel free to add extensions and types as you see fit.
Function Get-GopherType {
    [CmdletBinding()]
    [OutputType([Char])]
    Param(
        [ValidateNotNullOrEmpty()]
        [String] $Extension
    )

    # This list will be searched case-insensitively.
    $Extensions = @{
        'ace' = '9'                # ACE archive
        'ai' = 'I'                # Adobe Illustrator image
        'aif[cf]?' = '<'        # AIFF sound
        'applescript|scpt' = '0'# AppleScript code
        'arj' = '9'                # ARJ archive
        'art' = 'I'                # AOL ART image
        'asc' = '0'                # GPG data (text)
        'asf' = ';'                # ASF sound
        'asm|s' = '0'            # Assembly code
        'ass|ssa|srt' = '0'        # Subtitles
        'au' = '<'                # Sound
        'av1' = ';'                # AV1 movie
        'avi' = ';'                # AVI movie
        'avif' = 'I'            # AVIF image
        'bat|cmd' = '0'            # Batch file
        'bin' = '9'                # Generic binary
        'bmp|dib|pcx' = ':'        # Bitmap image
        'br' = '9'                # Brotli-compressed data
        'bz2' = '9'                # BZIP2 archive
        'c|h' = '0'                # C source code
        'cab' = '9'                # Windows cabinet
        'cer' = '0'                # Certificate (probably text)
        'cgm' = 'I'                # CGM image
        'coffee' = '0'            # CoffeeScript
        'conf|cfg?|ini' = '0'    # Config file
        'cpio' = '9'            # CPIO archive
        '[ch](?:pp|xx)' = '0'    # C++ code
        'crl' = '9'                # Certificate revocation list
        'crt' = '9'                # Certificate (probably binary)
        '[ch]s' = '0'            # C# code
        'css' = '0'                # CSS stylesheet
        'csv' = '0'                # CSV data
        'cur|ani' = '5'            # Windows cursor
        'deb|rpm|apk' = '9'        # Linux packages
        'der' = '0'                # Certificate (as text)
        'diff' = '0'            # diff
        'dll' = '5'                # DOS/Windows library
        'dmg|sparseimage' = '9'    # macOS disk image
        'dng' = 'I'                # Digital negative
        'dns' = '0'                # DNS zone
        'do[ct][mx]?' = 'd'        # Microsoft Word document
        'dsk' = '9'                # Disk image
        'dvi' = 'd'                # DVI document
        'dvr-ms' = ';'            # Windows Media Center movie
        'dwg' = 'I'                # AutoCAD image
        'ebuild' = '0'            # Gentoo ebuild
        'emf|wmf' = 'I'            # Windows metafile image
        'eml|msg' = '0'            # Email message
        'eps' = 'I'                # Vector image
        'epub|mobi' = '9'        # Book
        'exe|com|pif' = '5'        # DOS/Windows app
        'f?odg|otg' = 'I'        # OpenDocument drawing
        'f?odp|otp' = 'd'        # OpenDocument presentation
        'f?ods|ots' = 'd'        # OpenDocument spreadsheet
        'f?odt|ott' = 'd'        # OpenDocument document
        'fon|fot' = '5'            # DOS/Windows font
        'flac' = '<'            # FLAC audio
        'flv' = ';'                # Flash video
        'gif' = 'g'                # GIF image
        'gifv' = ';'            # GIFV video
        'gmi' = '0'                # Gemtext
        'gnumeric' = 'd'        # Gnumeric spreadsheet
        'go' = '0'                # Go source code
        'gpg' = '9'                # GPG data (binary)
        'gz' = '9'                # Compressed data
        'hei[cf]' = 'I'            # HEIC image
        'hqx' = '4'                # BinHex archive
        'html?' = 'h'            # HTML document
        'icns' = 'I'            # macOS icon
        'ico' = 'I'                # Windows icon
        'img' = '9'                # Disk image
        'inf' = '0'                # Windows INF file
        'ini' = '0'                # Configuration file
        'ipsw' = '9'            # iOS/iPod software update
        'iso' = '9'                # CD image
        'jar' = '9'                # Java app
        'java' = '0'            # Java source code
        'jp2' = 'I'                # JPEG 2000 image
        'jpe?g' = 'I'            # JPEG image
        'js' = '0'                # JavaScript code
        'json' = '0'            # JSON data
        'jsonld' = '0'            # JSON-LD data
        'jxl' = 'I'                # JPEG XL image
        'lnk' = '5'                # Windows shortcut
        'log' = '0'                # Log
        'lua' = '0'                # Lua source code
        'lz' = '9'                # Compressed data
        'lzh' = '9'                # Compressed data
        'lzma' = '9'            # Compressed data
        'lzo' = '9'                # Compressed data
        'm3u8?' = '<'            # Playlist
        'm4' = '0'                # M4 source code
        'm4[abpr]' = '<'        # MPEG-4 audio formats (mostly iTunes)
        'm4v|mp4' = ';'            # MPEG-4 container (usually video)
        'md|markdown' = '0'        # Markdown text
        'midi?' = '<'            # MIDI music
        'mkv' = ';'                # Matroska video
        'mov' = ';'                # QuickTime movie
        'mp3' = '<'                # MP3 audio
        'mpe?g' = ';'            # MPEG movie
        'mpp' = 'd'                # Microsoft Project document
        'msp' = 'I'                # Microsoft Paintbrush image
        'numbers' = 'd'            # Numbers spreadsheet
        'o' = '9'                # Object file
        'ocx' = '5'                # ActiveX control
        'odb' = 'd'                # OpenDoucment database
        'odf' = 'd'                # OpenDocument formula
        'og[gm]' = '<'            # Ogg audio
        'ogv' = ';'                # Ogg video
        'ovl' = '5'                # DOS overlay
        'o?xps' = 'd'            # (Open)XPS document
        'pages' = 'd'            # Pages document
        'par2?' = '9'            # PAR archive
        'pas' = '0'                # Pascal code
        'pbm' = ':'                # PBM image
        'pi?ct' = 'I'            # Apple PICT image
        'pdf' = 'd'                # PDF document
        'pdn' = 'I'                # Paint.NET image
        'pem' = '0'                # PEM-encoded data
        'pfx|p12|p7[bc]' = '9'    # Certificates
        'php[345]?' = '0'        # PHP code
        'pl' = '0'                # Perl code
        'png' = 'I'                # PNG image
        'pptx?|pps|pot' = 'd'    # PowerPoint presentation
        'ps' = 'd'                # PostScript document
        'psd' = 'I'                # Photoshop image
        'psp' = 'I'                # Paint Shop Pro image
        'ps[cdm]?1' = '0'        # PowerShell code
        'ps1xml' = '0'            # PowerShell types or formats
        'pub' = 'd'                # Publisher document
        'py' = '0'                # Python code
        'py[co]' = '9'            # Python bytecode
        'r' = '0'                # R code
        'rar' = '9'                # WinRAR archive
        'rb' = '0'                # Ruby source code
        'rdp' = '0'                # Microsoft Remote Desktop connection
        'rss|atom' = '0'        # News feed
        'rtfd?' = 'd'            # Rich Text Format document
        'scr' = '5'                # Windows screen saver
        'scss' = '0'            # Sass code
        'sh|bash|command' = '0'    # Shell script
        'sht(?:ml)?' = 'h'        # HTML with includes
        'sitx?' = '9'            # Stuffit archive
        'snd' = '<'                # Sound
        'sql' = '0'                # SQL code
        'svgz?' = 'I'            # SVG image
        'sys|drv' = '5'            # DOS/Windows driver
        'tab|tsv' = 'd'            # Tab-encoded values
        'tar' = '9'                # TAR archive
        'targa|tga' = 'I'        # Targa image
        'tcl' = '0'                # TCL code
        'tex' = '0'                # Tex code
        'tiff?' = 'I'            # TIFF image
        'tt[cf]|otf|woff2?' = '9'    # Font
        'txt' = '0'                # Text
        'uue' = '6'                # UUEncoded data
        'vbs' = '0'                # VBScript
        'vhdx?' = '9'            # Windows disk image
        'vsdx?' = 'I'            # Visio drawing
        'wav' = '<'                # Wave audio
        'webm' = ';'            # WebM video
        'webp|wp2' = 'I'        # WebP (2) image
        'wim|esd' = '9'            # Windows Image (archive)
        'wma|asf' = ';'            # Windows Media audio
        'wmf|emf' = 'I'            # Windows Metafile image
        'wmv' = ';'                # Windows Media video
        'wpt?' = 'd'            # WordPerfect doucment
        'wri' = 'd'                # Windows Write document
        'xcf' = 'I'                # GIMP image
        'xht(?:ml)?' = 'h'        # XHTML code
        'xl(s[bmx]?|w)' = 'd'    # Excel document
        'xltm?'    = 'd'            # Excel template
        'xml|rdf' = '0'            # XML code
        'xpm' = 'I'                # XPM image
        'xsd' = '0'                # XSD code
        'xslt?' = '0'            # XML stylesheet
        'xz' = '9'                # Compressed data
        'ya?ml' = '0'            # YAML code
        'Z' = '9'                # Compressed data
        'zip' = '9'                # Zip archive
        'zoo' = '9'                # ZOO archive
        '123' = 'd'                # Lotus 1-2-3 document
        '7z' = '9'                # 7-zip archive
    }

    $Result = $null
    ForEach ($regex in $Extensions.GetEnumerator()) {
        # "Testing extension $Extension against $($regex.Name)."
        Write-Debug (Get-MessageTranslation 28 @($Extension, $regex.Name))
        If ($Extension -Match $regex.Name) {
            $Result = $regex.Value
            Break
        }
    }
    # "Guessing that the extension ___ is of type $(___ ?? 'unknown')."
    Write-Verbose (Get-MessageTranslation 29 @($Extension, ($Result ?? (Get-MessageTranslation 30))))
    Return $Result
}

Function Get-MessageTranslation {
    [OutputType([String])]
    Param(
        [Parameter(Position=0, Mandatory)]
        [UInt16] $MessageID,

        [Parameter(Position=1)]
        [AllowNull()]
        [String[]] $Substitutions = @()
    )

    If ($null -eq $script:Translations) {
        Import-Translation
    }

    Return ($script:Translations[$MessageID - 1] -f $Substitutions)
}

Function Import-Translation {
    # Error messages in this function indicate that a localization does
    # not exist, could not be loaded, or has not yet been loaded and any
    # lookup would cause an infinite loop. Do not translate these
    # errors; leave them in English.
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Switch] $ForceEnUs
    )

    $Language = (Get-Culture)

    Try {
        # "Attempting to load translations for <your language here>."
        Write-Debug "Attempting to load translations for $($Language.Name)"
        $File = Join-Path -Path (Get-Module 'PSGopher').ModuleBase -ChildPath $Language.Name -AdditionalChildPath 'translations.json'
        $script:Translations = Get-Content -Path $File -Encoding 'UTF8' | ConvertFrom-Json
    }
    Catch {
        If (-Not $ForceEnUs) {
            Write-Debug "Falling back to English (United States)"
            Import-Translation -ForceEnUs:$true
        }
        Else {
            Throw 'Failed to load en-US translation!'
        }
    }
    Return
}

# SIG # Begin signature block
# MIIo5AYJKoZIhvcNAQcCoIIo1TCCKNECAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDB0KkG5Jks/aEU
# 04WHAPI/dlaNF1R6168hwEiQqCIIraCCI6swggR+MIIC5qADAgECAhEApna5vdQ8
# txEq0UQhUxLsMzANBgkqhkiG9w0BAQwFADBBMQswCQYDVQQGEwJVUzEQMA4GA1UE
# ChMHQ2VydGVyYTEgMB4GA1UEAxMXQ2VydGVyYSBDb2RlIFNpZ25pbmcgQ0EwHhcN
# MjIxMTI1MDAwMDAwWhcNMjUxMTI0MjM1OTU5WjBPMQswCQYDVQQGEwJVUzEUMBIG
# A1UECAwLQ29ubmVjdGljdXQxFDASBgNVBAoMC0NvbGluIENvZ2xlMRQwEgYDVQQD
# DAtDb2xpbiBDb2dsZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIS0nGDy1zQpFKyt
# Jcg1PiDfvpNR79NCbfgewfNj/SLANVb3XbggjeibCl1fcefKLnXFv0DXHIKjYg0e
# hcFMbUQ1hqpwnnWQji1DcLeshAMdvWmTguYmtL6P4ik/BQDUuaOCAY8wggGLMB8G
# A1UdIwQYMBaAFP7HyA+eaTU9w8t0+WyaszQGqVwJMB0GA1UdDgQWBBSO8z1ie4Xj
# RAjUjX9ctrNH9aglYzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADATBgNV
# HSUEDDAKBggrBgEFBQcDAzBJBgNVHSAEQjBAMDQGCysGAQQBsjEBAgJlMCUwIwYI
# KwYBBQUHAgEWF2h0dHBzOi8vc2VjdGlnby5jb20vQ1BTMAgGBmeBDAEEATBIBgNV
# HR8EQTA/MD2gO6A5hjdodHRwOi8vQ2VydGVyYS5jcmwuc2VjdGlnby5jb20vQ2Vy
# dGVyYUNvZGVTaWduaW5nQ0EuY3JsMIGABggrBgEFBQcBAQR0MHIwQwYIKwYBBQUH
# MAKGN2h0dHA6Ly9DZXJ0ZXJhLmNydC5zZWN0aWdvLmNvbS9DZXJ0ZXJhQ29kZVNp
# Z25pbmdDQS5jcnQwKwYIKwYBBQUHMAGGH2h0dHA6Ly9DZXJ0ZXJhLm9jc3Auc2Vj
# dGlnby5jb20wDQYJKoZIhvcNAQEMBQADggGBAAslTgxzcZ0FYetE3IOghFsEtGV+
# yEM03ZrGFRGt7/DmHe4MK15XUsORJzN60eyNzxchQhV1S90jqQflkl6ImuvdaRve
# 586ZhYtW4tl2+2YbM26jwVqB9tT06W1SHb03+Vb29jjRbp5r+w3lEXxzGC660MFk
# 1L8kRQcqKjt0izVeVm6qKfNVQyak5xWpeX8n8NVaCqVWfijWlLDr8Ydeg9XeJy4H
# c9OweQ7+seRJzr/MgHQ0SFuXaRrbk0v5UmyoH83LZt/qo+XnrU+XeX870UVxucTl
# AitkDB6t/dvmetmXQGE5stJMyIK5jgtMqQ/q/GIrTFYMmcAsXxNQh8uv+jFa0HhF
# PZVhhdRbximJQUPyKb7IMuAzwdw1jrTcAF1FbkLlHXdu7dohbSfsN8ZA5Cr397wN
# n7UBs939mMBb4ZR+nBPFhibj5RISssbICi8z3LNb6CNuayOn3PtG/NRcf5T8iFyW
# /XbipYDJcxuQKwP8HWmlVIfQooRP6HR+Doee+DCCBY0wggR1oAMCAQICEA6bGI75
# 0C3n79tQ4ghAGFowDQYJKoZIhvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNV
# BAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIG
# A1UEAxMbRGlnaUNlcnQgQXNzdXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAw
# MFoXDTMxMTEwOTIzNTk1OVowYjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lD
# ZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGln
# aUNlcnQgVHJ1c3RlZCBSb290IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
# CgKCAgEAv+aQc2jeu+RdSjwwIjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuE
# DcQwH/MbpDgW61bGl20dq7J58soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNw
# wrK6dZlqczKU0RBEEC7fgvMHhOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs0
# 6wXGXuxbGrzryc/NrDRAX7F6Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e
# 5TXnMcvak17cjo+A2raRmECQecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtV
# gkEy19sEcypukQF8IUzUvK4bA3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85
# tRFYF/ckXEaPZPfBaYh2mHY9WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+S
# kjqePdwA5EUlibaaRBkrfsCUtNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1Yxw
# LEFgqrFjGESVGnZifvaAsPvoZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzl
# DlJRR3S+Jqy2QXXeeqxfjT/JvNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFr
# b7GrhotPwtZFX50g/KEexcCPorF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATow
# ggE2MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiu
# HA9PMB8GA1UdIwQYMBaAFEXroq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQE
# AwIBhjB5BggrBgEFBQcBAQRtMGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
# Z2ljZXJ0LmNvbTBDBggrBgEFBQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
# Y29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2
# hjRodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290
# Q0EuY3JsMBEGA1UdIAQKMAgwBgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/
# Q1xV5zhfoKN0Gz22Ftf3v1cHvZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNK
# ei8ttzjv9P+Aufih9/Jy3iS8UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHr
# lnKhSLSZy51PpwYDE3cnRNTnf+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4
# oVaO7KTVPeix3P0c2PR3WlxUjG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5A
# Y8WYIsGyWfVVa88nq2x2zm8jLfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNN
# n3O3AamfV6peKOK5lDCCBd4wggPGoAMCAQICEAH9bTD8o8pRqBu8ZA41Ay0wDQYJ
# KoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5
# MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO
# ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0
# aG9yaXR5MB4XDTEwMDIwMTAwMDAwMFoXDTM4MDExODIzNTk1OVowgYgxCzAJBgNV
# BAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtKZXJzZXkgQ2l0
# eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYDVQQDEyVVU0VS
# VHJ1c3QgUlNBIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0B
# AQEFAAOCAg8AMIICCgKCAgEAgBJlFzYOw9sIs9CsVw127c0n00ytUINh4qogTQkt
# ZAnczomfzD2p7PbPwdzx07HWezcoEStH2jnGvDoZtF+mvX2do2NCtnbyqTsrkfji
# b9DsFiCQCT7i6HTJGLSR1GJk23+jBvGIGGqQIjy8/hPwhxR79uQfjtTkUcYRZ0YI
# UcuGFFQ/vDP+fmyc/xadGL1RjjWmp2bIcmfbIWax1Jt4A8BQOujM8Ny8nkz+rwWW
# NR9XWrf/zvk9tyy29lTdyOcSOk2uTIq3XJq0tyA9yn8iNK5+O2hmAUTnAU5GU5sz
# YPeUvlM3kHND8zLDU+/bqv50TmnHa4xgk97Exwzf4TKuzJM7UXiVZ4vuPVb+DNBp
# DxsP8yUmazNt925H+nND5X4OpWaxKXwyhGNVicQNwZNUMBkTrNN9N6frXTpsNVzb
# QdcS2qlJC9/YgIoJk2KOtWbPJYjNhLixP6Q5D9kCnusSTJV882sFqV4Wg8y4Z+Lo
# E53MW4LTTLPtW//e5XOsIzstAL81VXQJSdhJWBp/kjbmUZIO8yZ9HE0XvMnsQybQ
# v0FfQKlERPSZ51eHnlAfV1SoPv10Yy+xUGUJ5lhCLkMaTLTwJUdZ+gQek9QmRkpQ
# gbLevni3/GcV4clXhB4PY9bpYrrWX1Uu6lzGKAgEJTm4Diup8kyXHAc/DVL17e8v
# gg8CAwEAAaNCMEAwHQYDVR0OBBYEFFN5v1qqK0rPVIDh2JvAnfKyA2bLMA4GA1Ud
# DwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQBc
# 1HwNz/cBfUGZZQxzxVKfy/jPmQZ/G9pDFZ+eAlVXlhTxUjwnh5Qo7R86ATeidvxT
# UMCEm8ZrTrqMIU+ijlVikfNpFdi8iOPEqgv976jpS1UqBiBtVXgpGe5fMFxLJBFV
# /ySabl4qK+4LTZ9/9wE4lBSVQwcJ+2Cp7hyrEoygml6nmGpZbYs/CPvI0UWvGBVk
# kBIPcyguxeIkTvxY7PD0Rf4is+svjtLZRWEFwZdvqHZyj4uMNq+/DQXOcY3mpm8f
# bKZxYsXY0INyDPFnEYkMnBNMcjTfvNVx36px3eG5bIw8El1l2r1XErZDa//l3k1m
# EVHPma7sF7bocZGM3kn+3TVxohUnlBzPYeMmu2+jZyUhXebdHQsuaBs7gq/sg2eF
# 1JhRdLG5mYCJ/394GVx5SmAukkCuTDcqLMnHYsgOXfc2W8rgJSUBtN0aB5x3AD/Q
# 3NXsPdT6uz/MhdZvf6kt37kC9/WXmrU12sNnsIdKqSieI47/XCdr4bBP8wfuAC7U
# WYfLUkGV6vRH1+5kQVV8jVkCld1incK57loodISlm7eQxwwH3/WJNnQy1ijBsLAL
# 4JxMwxzW/ONptUdGgS+igqvTY0RwxI3/LTO6rY97tXCIrj4Zz0Ao2PzIkLtdmSL1
# UuZYxR+IMUPuiB3Xxo48Q2odpxjefT0W8WL5ypCo/TCCBjwwggQkoAMCAQICECFm
# 8IpR6/yrzI9EMJGpSw4wDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMw
# EQYDVQQIEwpOZXcgSmVyc2V5MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UE
# ChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgUlNB
# IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTIyMDkwNzAwMDAwMFoXDTMyMDkw
# NjIzNTk1OVowQTELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0NlcnRlcmExIDAeBgNV
# BAMTF0NlcnRlcmEgQ29kZSBTaWduaW5nIENBMIIBojANBgkqhkiG9w0BAQEFAAOC
# AY8AMIIBigKCAYEAvp9xPhzayPelQMu7ycbIP8Kls73mzciRa7hO+f06rZl7Xw4F
# DKuA1Cu7nen1GFCPuqRvCqEizDiO4/WnM4nQcfVFkfpXfZf24qUztHzq5qsxlwpK
# W/Dkksj+I9A15W1dFbmToYswFElXzmKHSnZXoYMz+R4ZSwmnVB/XsvUPaAFi2dCr
# KN54pMcsBweUOKFunKWkji/MMnnPJGebOF1fLeDgyEHQvYuzlVfOWU3xjMiZYfqY
# gi8jo28qa0IYR17SdFZIgUWRlKhJnNKwyXfY8kElpfpeSbjM20jLch1+UhPXwTU/
# 5yHwXvUCSW4idXEihxbcleNXbeO8wfwfNHn2of4Y1w4mShxHFhDu/kPmzDIkpPct
# AmDyJfJfcL1E+aRFqGYhJwCOiMNQE9dfDkYL11Rtue3zmcpkqKbH6P6EI3UQSG1t
# H0OqY65xpSadXS/yGoXqOOEQpDf/U3trlyqroxhUhm0dN82CBqSXqMa23scYns1O
# 3u2kSPPHIEULOVq5AgMBAAGjggFmMIIBYjAfBgNVHSMEGDAWgBRTeb9aqitKz1SA
# 4dibwJ3ysgNmyzAdBgNVHQ4EFgQU/sfID55pNT3Dy3T5bJqzNAapXAkwDgYDVR0P
# AQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUH
# AwMwIgYDVR0gBBswGTANBgsrBgEEAbIxAQICZTAIBgZngQwBBAEwUAYDVR0fBEkw
# RzBFoEOgQYY/aHR0cDovL2NybC51c2VydHJ1c3QuY29tL1VTRVJUcnVzdFJTQUNl
# cnRpZmljYXRpb25BdXRob3JpdHkuY3JsMHEGCCsGAQUFBwEBBGUwYzA6BggrBgEF
# BQcwAoYuaHR0cDovL2NydC51c2VydHJ1c3QuY29tL1VTRVJUcnVzdFJTQUFBQUNB
# LmNydDAlBggrBgEFBQcwAYYZaHR0cDovL29jc3AudXNlcnRydXN0LmNvbTANBgkq
# hkiG9w0BAQwFAAOCAgEAe11w9/hMUEgtubZdffaBE4vbRYL0hunnc2Yaup6rzig/
# GjVOaTA7gdoChGhuxDE0AoYMF1znfLBSuNrU6B8tO/ikxFprLayPz9IUmbhEd/Ry
# VbMimZiC7z74OfjIVx86Y279nJ0VmX6lgHvwc8QcAVMN00Qse97OD9EeWMuY+hB7
# 1mKUp6pTipoqKJD4+hs2fOxjXew9OBYu6wjlgK6kbuBo+R2T7EuYyyfWubg9Cpwg
# dzRSpWmRO5DMG+u0FojEtP8MITbtJ1bLOWZ0JVvGKDWqNLVBvxHE8DwaAx3IrlZ8
# 1lxLO3zEL/mpUnC6cdQlVkq3G7qdWfIdkaNhNAv3hu0tH3t8bLoXYDB6Kyp5hdGZ
# 1XAO7H4b7MVW1amciuBXys6/VvfWmR/9Wh1rjWuYtP+y94oLg1gEisa7+Qid2qy/
# WSKC7cjpzwmg+6BGb2oEAO56pZToRc5a8vE9XcMPMO6hxI+MGbpqioQ/Nwa+94Ep
# D2aGUkmqX3gP6kUBbvS4Pys0jLgKxlyZDfwJb+4CWQOoZaiZoLAr/Y9+9j2YkeQD
# rt1A2zEDgOHRLlXYQDPuVNSu014pt8yAMY1OnHQSrTKwBZ2Y5H8AOw1yyIsMQISq
# OcPiepvzMAwSMJtTedvFq51+kuBHgltH2AdDlPfT13i3CAqn3LcFhehUZU4VIPsw
# ggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1
# c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbS
# g9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9
# /UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXn
# HwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0
# VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4f
# sbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40Nj
# gHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0
# QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvv
# mz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T
# /jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk
# 42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5r
# mQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4E
# FgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5n
# P+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcG
# CCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu
# Y29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8v
# Y3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNV
# HSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIB
# AH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxp
# wc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIl
# zpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQ
# cAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfe
# Kuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+j
# Sbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJsh
# IUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6
# OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDw
# N7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR
# 81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2
# VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIGwDCCBKigAwIBAgIQ
# DE1pckuU+jwqSj0pB4A9WjANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEX
# MBUGA1UEChMORGlnaUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0
# ZWQgRzQgUlNBNDA5NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIyMDkyMTAw
# MDAwMFoXDTMzMTEyMTIzNTk1OVowRjELMAkGA1UEBhMCVVMxETAPBgNVBAoTCERp
# Z2lDZXJ0MSQwIgYDVQQDExtEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMiAtIDIwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDP7KUmOsap8mu7jcENmtuh6BSF
# dDMaJqzQHFUeHjZtvJJVDGH0nQl3PRWWCC9rZKT9BoMW15GSOBwxApb7crGXOlWv
# M+xhiummKNuQY1y9iVPgOi2Mh0KuJqTku3h4uXoW4VbGwLpkU7sqFudQSLuIaQyI
# xvG+4C99O7HKU41Agx7ny3JJKB5MgB6FVueF7fJhvKo6B332q27lZt3iXPUv7Y3U
# TZWEaOOAy2p50dIQkUYp6z4m8rSMzUy5Zsi7qlA4DeWMlF0ZWr/1e0BubxaompyV
# R4aFeT4MXmaMGgokvpyq0py2909ueMQoP6McD1AGN7oI2TWmtR7aeFgdOej4TJEQ
# ln5N4d3CraV++C0bH+wrRhijGfY59/XBT3EuiQMRoku7mL/6T+R7Nu8GRORV/zbq
# 5Xwx5/PCUsTmFntafqUlc9vAapkhLWPlWfVNL5AfJ7fSqxTlOGaHUQhr+1NDOdBk
# +lbP4PQK5hRtZHi7mP2Uw3Mh8y/CLiDXgazT8QfU4b3ZXUtuMZQpi+ZBpGWUwFjl
# 5S4pkKa3YWT62SBsGFFguqaBDwklU/G/O+mrBw5qBzliGcnWhX8T2Y15z2LF7OF7
# ucxnEweawXjtxojIsG4yeccLWYONxu71LHx7jstkifGxxLjnU15fVdJ9GSlZA076
# XepFcxyEftfO4tQ6dwIDAQABo4IBizCCAYcwDgYDVR0PAQH/BAQDAgeAMAwGA1Ud
# EwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwIAYDVR0gBBkwFzAIBgZn
# gQwBBAIwCwYJYIZIAYb9bAcBMB8GA1UdIwQYMBaAFLoW2W1NhS9zKXaaL3WMaiCP
# nshvMB0GA1UdDgQWBBRiit7QYfyPMRTtlwvNPSqUFN9SnDBaBgNVHR8EUzBRME+g
# TaBLhklodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRS
# U0E0MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3JsMIGQBggrBgEFBQcBAQSBgzCB
# gDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFgGCCsGAQUF
# BzAChkxodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVk
# RzRSU0E0MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3J0MA0GCSqGSIb3DQEBCwUA
# A4ICAQBVqioa80bzeFc3MPx140/WhSPx/PmVOZsl5vdyipjDd9Rk/BX7NsJJUSx4
# iGNVCUY5APxp1MqbKfujP8DJAJsTHbCYidx48s18hc1Tna9i4mFmoxQqRYdKmEIr
# UPwbtZ4IMAn65C3XCYl5+QnmiM59G7hqopvBU2AJ6KO4ndetHxy47JhB8PYOgPvk
# /9+dEKfrALpfSo8aOlK06r8JSRU1NlmaD1TSsht/fl4JrXZUinRtytIFZyt26/+Y
# siaVOBmIRBTlClmia+ciPkQh0j8cwJvtfEiy2JIMkU88ZpSvXQJT657inuTTH4YB
# ZJwAwuladHUNPeF5iL8cAZfJGSOA1zZaX5YWsWMMxkZAO85dNdRZPkOaGK7DycvD
# +5sTX2q1x+DzBcNZ3ydiK95ByVO5/zQQZ/YmMph7/lxClIGUgp2sCovGSxVK05iQ
# RWAzgOAj3vgDpPZFR+XOuANCR+hBNnF3rf2i6Jd0Ti7aHh2MWsgemtXC8MYiqE+b
# vdgcmlHEL5r2X6cnl7qWLoVXwGDneFZ/au/ClZpLEQLIgpzJGgV8unG1TnqZbPTo
# ntRamMifv427GFxD9dAq6OJi7ngE273R+1sKqHB+8JeEeOMIA11HLGOoJTiXAdI/
# Otrl5fbmm9x+LMz/F0xNAKLY1gEOuIvu5uByVYksJxlh9ncBjDGCBI8wggSLAgEB
# MFYwQTELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0NlcnRlcmExIDAeBgNVBAMTF0Nl
# cnRlcmEgQ29kZSBTaWduaW5nIENBAhEApna5vdQ8txEq0UQhUxLsMzANBglghkgB
# ZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJ
# AzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8G
# CSqGSIb3DQEJBDEiBCCVgmrv04882Tf1lYGGVfoMLuSiDo6hyRKy8H5qbzVjZTAL
# BgcqhkjOPQIBBQAEZzBlAjEAt84Bf0HqRZA1t0PIit9tSXG2v02jJbikWfN4AmjP
# bnwT2gnZBXhD/bC4YiBBUuFCAjAxQeKEQuscalwrVy9vBdllUW6kCir4gqWkvkFZ
# qCm3uPxbwKsV7dxo0LJk4NxvSfKhggMgMIIDHAYJKoZIhvcNAQkGMYIDDTCCAwkC
# AQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xOzA5
# BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYgVGltZVN0
# YW1waW5nIENBAhAMTWlyS5T6PCpKPSkHgD1aMA0GCWCGSAFlAwQCAQUAoGkwGAYJ
# KoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjMwMzI5MTQz
# NDIxWjAvBgkqhkiG9w0BCQQxIgQgfFtYeXKJ2Gnj5zQZnc+8NHgM/RE30uwwGo5U
# i0abca0wDQYJKoZIhvcNAQEBBQAEggIAmPlKG959Yd66VzIhFWIKlt/18ZCnsyAR
# gDmGbtvP3VeejpaEGFWM0nPMNnxkQ1W7Z0rqyKPkHso6HMZbJW3aDAwjKPBy6NP3
# XqIDUJx5gqwOcptRGhr2NEwuX/tauSboTcyDNesgP9gNk3LJ9HDlF3WTom26+zKa
# LSOPeSkQCizqcmbIekJsXIMiFQ+3/St87AoLiWaLq1FPsShX7S+JXVx13qgYTMlt
# +rFlC9TBTG2Lrnbpt9reSlkUlm5NCV5teD+KTdkZ/LSG9uePZ7kjsorTZVZ24cdy
# rGO4O/dFqSZ4MKHO1iR6umgIDfNKht86re82TiaAyh3f8Zm7yB29qN+68O8kDk34
# pHlxs0L9wsSghATZeaXO2fc1sN4X2SV+FASVTRIozhJe+/T1IG2pSsz5Eo2S4Ljo
# raioH42qZFf42Y1XkKv+3Q5tgdb+TlR35mL4WKJIH9DFFr2ZkbzjqUNzOSqvIhI/
# C47NvtE098CVhzfuDDIG/I+BSsUc+/u5sRdnQXs+yn+sBcQ9dz1nkd4bGFbm4nx8
# wIoPPzD0gk1jTaBpecTDI7pZv6IVQ1xvjXII3GVp3ctqWS42Ckrx40oxsyPfS5vN
# vYL0B/36Ufd4bUJ0LjTJHBqC++SUHnhNWLpi8dGg2rGWzGWDCTrCASysiJfl1+4F
# 1iZBvAor7bo=
# SIG # End signature block