PSPuTTYCfg.psm1

# See the help for Set-StrictMode for what this enables
Set-StrictMode -Version 3.0

# Global variables (set by Initialize-PuTTYCfg on first invocation)
$Initialized = $false
$CfgData = [PSCustomObject]@{
    Json     = $null
    Registry = $null
}

# Module constants
Set-Variable -Option ReadOnly -Scope Script -Name 'JsonSchemaUri' -Value 'https://raw.githubusercontent.com/ralish/PSPuTTYCfg/stable/schemas/session.jsonc'

# JSON session constants
Set-Variable -Option ReadOnly -Scope Script -Name 'JsonValidExts' -Value @('.json', '.json')

# Registry session constants
Set-Variable -Option ReadOnly -Scope Script -Name 'RegSessionsPath' -Value 'HKCU:\SOFTWARE\SimonTatham\PuTTY\Sessions'
Set-Variable -Option ReadOnly -Scope Script -Name 'RegIgnoredSettings' -Value @(
    'BoldFont'
    'BoldFontCharSet'
    'BoldFontHeight'
    'BoldFontIsBold'
    'LoginShell'
    'NetHackKeypad'
    'PingInterval'
    'Present'
    'ScrollbarOnLeft'
    'ShadowBold'
    'ShadowBoldOffset'
    'StampUtmp'
    'TerminalModes'
    'UTF8Override'
    'WideBoldFont'
    'WideBoldFontCharSet'
    'WideBoldFontHeight'
    'WideBoldFontIsBold'
    'WideFont'
    'WideFontCharSet'
    'WideFontHeight'
    'WideFontIsBold'
    'WindowClass'
    'Wordness0'
    'Wordness32'
    'Wordness64'
    'Wordness96'
    'Wordness128'
    'Wordness160'
    'Wordness192'
    'Wordness224'
)

# PuTTY session
Class PuTTYSession {
    [String]$Name
    [String]$Origin
    [String[]]$Inherits
    [PSCustomObject]$Settings

    PuTTYSession([String]$Name, [String]$Origin) {
        $this.Name = $Name
        $this.Origin = $Origin
        $this.Inherits = @()
        $this.Settings = [PSCustomObject]@{
            '$schema' = $Script:JsonSchemaUri
        }
    }

    [String] ToString() {
        return 'PuTTY Session: {0}' -f $this.Name
    }
}

Function Export-PuTTYSession {
    <#
        .SYNOPSIS
        Exports PuTTY sessions to JSON files or the Windows registry
 
        .DESCRIPTION
        After importing PuTTY sessions they can be exported to a supported destination using this command.
 
        The supported destinations are to JSON files or the Windows registry under the PuTTY Sessions key.
 
        .PARAMETER Session
        PuTTY sessions to operate on as returned by a previous invocation of Import-PuTTYSession.
 
        .PARAMETER Path
        File system path where exported PuTTY sessions will be saved in JSON format.
 
        The destination directory must already exist.
 
        .PARAMETER Registry
        Export PuTTY sessions to the Windows registry as used by PuTTY.
 
        The PuTTY Sessions key must already exist.
 
        .PARAMETER Defaults
        The baseline defaults to use for unspecified settings when exporting to the Windows registry.
 
        The default is the PuTTY v0.82 defaults, however, earlier PuTTY versions are also supported.
 
        .PARAMETER Force
        Permit overwriting of existing PuTTY sessions.
 
        .EXAMPLE
        $Sessions | Export-PuTTYSession -Path $HOME\PuTTY
 
        Exports PuTTY sessions in the $Sessions variable to the $HOME\PuTTY directory.
 
        .EXAMPLE
        $Sessions | Export-PuTTYSession -Registry -Force
 
        Exports PuTTY sessions in the $Sessions variable to the PuTTY Sessions key. Matching existing PuTTY sessions will be overwritten.
 
        .LINK
        https://github.com/ralish/PSPuTTYCfg
    #>


    [CmdletBinding(DefaultParameterSetName = 'Json')]
    [OutputType([Void], [PSCustomObject[]])]
    Param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PuTTYSession]$Session,

        [Parameter(ParameterSetName = 'Json', Mandatory)]
        [String]$Path,

        [Parameter(ParameterSetName = 'Registry', Mandatory)]
        [Switch]$Registry,

        [Parameter(ParameterSetName = 'Registry')]
        [ValidateSet('0.70', '0.71', '0.72', '0.73', '0.74', '0.75', '0.76', '0.77', '0.78', '0.79', '0.80', '0.81', '0.82')]
        [String]$Defaults = '0.82',

        [Switch]$Force
    )

    Begin {
        Initialize-PuTTYCfg
        $Sessions = [Collections.Generic.List[PuTTYSession]]::new()
    }

    Process {
        $Sessions.Add($Session)
    }

    End {
        switch ($PSCmdlet.ParameterSetName) {
            'Json' { Export-PuTTYSessionToJson -Session $Sessions -Path $Path -Force:$Force }
            'Registry' { Export-PuTTYSessionToRegistry -Session $Sessions -Defaults $Defaults -Force:$Force }
            Default { throw 'Unknown provider: {0}' -f $PSCmdlet.ParameterSetName }
        }
    }
}

Function Import-PuTTYSession {
    <#
        .SYNOPSIS
        Imports PuTTY sessions from JSON files or the Windows registry
 
        .DESCRIPTION
        After importing PuTTY sessions using this command they can be exported to a supported destination.
 
        The supported sources are from JSON files or the Windows registry under the PuTTY Sessions key.
 
        .PARAMETER Path
        File system path where PuTTY sessions saved in JSON format will be imported.
 
        .PARAMETER Recurse
        Recurse into subdirectories under the provided file system path during import.
 
        .PARAMETER Registry
        Import PuTTY sessions from the Windows registry as used by PuTTY.
 
        .PARAMETER ExcludeDefault
        Exclude settings which match PuTTY's defaults when importing (i.e. only import customised settings).
 
        Currently this switch only supports using the defaults from PuTTY v0.82.
 
        .PARAMETER Filter
        Only import sessions where the session name matches the provided glob pattern.
 
        .EXAMPLE
        $Sessions = Import-PuTTYSession -Path $HOME\PuTTY
 
        Imports PuTTY sessions stored as JSON files in the $HOME\PuTTY directory.
 
        .EXAMPLE
        $Sessions = Import-PuTTYSession -Registry -Filter 'Personal*'
 
        Imports PuTTY sessions from the PuTTY Sessions key matching the glob pattern "Personal*".
 
        .LINK
        https://github.com/ralish/PSPuTTYCfg
    #>


    [CmdletBinding(DefaultParameterSetName = 'Json')]
    [OutputType([Void], [PSCustomObject[]])]
    Param(
        [Parameter(ParameterSetName = 'Json', Mandatory)]
        [String]$Path,

        [Parameter(ParameterSetName = 'Json')]
        [Switch]$Recurse,

        [Parameter(ParameterSetName = 'Registry', Mandatory)]
        [Switch]$Registry,

        [Parameter(ParameterSetName = 'Registry')]
        [Switch]$ExcludeDefault,

        [String]$Filter
    )

    Begin {
        Initialize-PuTTYCfg

        $ImportParams = @{}
        if ($Filter) {
            $ImportParams['Filter'] = $Filter
        }
    }

    Process {
        switch ($PSCmdlet.ParameterSetName) {
            'Json' { Import-PuTTYSessionFromJson -Path $Path -Recurse:$Recurse @ImportParams }
            'Registry' { Import-PuTTYSessionFromRegistry -ExcludeDefault:$ExcludeDefault @ImportParams }
            Default { throw 'Unknown provider: {0}' -f $PSCmdlet.ParameterSetName }
        }
    }
}

Function Initialize-PuTTYCfg {
    [CmdletBinding()]
    [OutputType([Void])]
    Param()

    if ($Initialized) {
        return
    }

    Write-Debug -Message 'Loading configuration data ...'
    $Path = Join-Path -Path $PSScriptRoot -ChildPath 'PSPuTTYCfg.jsonc'

    # Remove JSON comments under PowerShell 5.x before we deserialize the
    # configuration data, as they're not supported by the JSON parser.
    if ($PSVersionTable.PSVersion.Major -eq 5) {
        $ContentJsonC = Get-Content -LiteralPath $Path -ErrorAction Stop
        $ContentJson = [Collections.Generic.List[String]]::new()

        foreach ($Line in $ContentJsonC) {
            if ($Line -match '//') {
                $NoComment = $Line.Substring(0, $Line.IndexOf('//'))

                # Skip lines which only contain a commennt
                if ([String]::IsNullOrWhiteSpace($NoComment)) {
                    continue
                }

                $Line = $NoComment
            }

            $ContentJson.Add($Line)
        }

        $Content = $ContentJson -Join [Environment]::NewLine
    } else {
        $Content = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
    }

    $Data = $Content | ConvertFrom-Json -ErrorAction Stop
    Write-Debug -Message ('Loaded {0} PuTTY settings.' -f $Data.settings.Count)

    Write-Debug -Message 'Building JSON to Registry setting hashtable ...'
    $JsonSettings = @{}
    foreach ($Setting in $Data.settings) {
        $SettingKey = '{0}/{1}' -f $Setting.json.path, $Setting.json.name
        $JsonSettings[$SettingKey] = $Setting
    }
    $Script:CfgData.Json = $JsonSettings

    Write-Debug -Message 'Building Registry to JSON setting hashtable ...'
    $RegistrySettings = @{}
    foreach ($Setting in $Data.settings) {
        $SettingKey = $Setting.reg.name
        $RegistrySettings[$SettingKey] = $Setting
    }
    $Script:CfgData.Registry = $RegistrySettings

    $Script:Initialized = $true
}

#region .NET sessions

Function Add-PuTTYSetting {
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession]$Session,

        [Parameter(Mandatory)]
        [PSCustomObject]$SettingData,

        [Parameter(Mandatory)]
        [Object]$Value,

        [Switch]$Force
    )

    $Settings = $Session.Settings
    $SettingName = $SettingData.json.name
    $SettingPath = $SettingData.json.path
    $CurrentPath = [String]::Empty

    foreach ($PathElement in $SettingPath.TrimStart('/').Split('/')) {
        $CurrentPath = '{0}/{1}' -f $CurrentPath, $PathElement

        if ($Settings.PSObject.Properties[$PathElement]) {
            $PathProperty = $Settings.$PathElement

            if ($PathProperty -isnot [PSCustomObject]) {
                throw '[{0}] Unexpected type at path "{1}" of settings object: {2}' -f $Session.Name, $CurrentPath, $PathProperty.GetType().Name
            }
        } else {
            $Settings | Add-Member -NotePropertyName $PathElement -NotePropertyValue ([PSCustomObject]@{})
        }

        $Settings = $Settings.$PathElement
    }

    $Settings | Add-Member -NotePropertyName $SettingName -NotePropertyValue $Value -Force:$Force
}

Function Merge-PuTTYSettings {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession]$Session,

        [Parameter(Mandatory)]
        [PSCustomObject]$Settings,

        [String]$CurrentPath = '/'
    )

    foreach ($Property in $Settings.PSObject.Properties) {
        if ($Property.MemberType -ne 'NoteProperty') {
            throw '[{0}] Unexpected member type at path "{1}" of settings object: {2}' -f $Session.Name, $CurrentPath, $Property.MemberType
        }

        $SettingName = $Property.Name
        if ($CurrentPath -eq '/' -and $SettingName -eq '$schema') {
            continue
        }

        $Setting = $Settings.$SettingName
        $SettingType = $Setting.GetType().Name

        if ($CurrentPath.EndsWith('/')) {
            $SettingPath = '{0}{1}' -f $CurrentPath, $SettingName
        } else {
            $SettingPath = '{0}/{1}' -f $CurrentPath, $SettingName
        }

        if ($SettingType -eq 'PSCustomObject') {
            Merge-PuTTYSettings -Session $Session -Settings $Setting -CurrentPath $SettingPath
            continue
        }

        if (!$CfgData.Json.ContainsKey($SettingPath)) {
            Write-Warning -Message ('[{0}] Ignoring unknown JSON setting: {1}' -f $Session.Name, $SettingPath)
            continue
        }

        $SettingData = $CfgData.Json[$SettingPath]
        Add-PuTTYSetting -Session $Session -SettingData $SettingData -Value $Setting -Force
    }
}

#endregion

#region JSON sessions

Function Add-PuTTYSessionJsonInherit {
    [Cmdletbinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession]$Session,

        [Parameter(Mandatory)]
        [String]$InheritedSessionName,

        [Collections.Generic.List[String]]$ProcessedSessions
    )

    if (!$ProcessedSessions) {
        $ProcessedSessions = [Collections.Generic.List[String]]::new()
    }

    Write-Debug -Message ('[{0}] Processing inherited JSON session: {1}' -f $Session.Name, $InheritedSessionName)

    if ($Session.Name -eq $InheritedSessionName -or $ProcessedSessions -contains $InheritedSessionName) {
        throw 'Circular inheritance detected processing inherited session "{0}" specified by session: {1}' -f $InheritedSessionName, $Session.Name
    }

    $InheritedSessionPath = Join-Path -Path (Split-Path -Path $Session.Origin -Parent) -ChildPath ('{0}.json' -f $InheritedSessionName)
    try {
        $InheritedJsonContent = Get-Content -LiteralPath $InheritedSessionPath -Raw -ErrorAction Stop
        $InheritedJsonSettings = $InheritedJsonContent | ConvertFrom-Json -ErrorAction Stop
    } catch {
        Write-Warning -Message ('Failed to load inherited session "{0}" specified by session: {1}' -f $InheritedSessionName, $Session.Name)
        throw $_
    }

    $ProcessedSessions.Add($InheritedSessionName)

    if ($InheritedJsonSettings.PSObject.Properties['inherits']) {
        $InheritedJsonSessions = $InheritedJsonSettings.inherits

        foreach ($InheritedJsonSession in $InheritedJsonSessions) {
            Add-PuTTYSessionJsonInherit -Session $Session -InheritedSessionName $InheritedJsonSession -ProcessedSessions $ProcessedSessions
        }

        $InheritedJsonSettings.PSObject.Properties.Remove('inherits')
    }

    Merge-PuTTYSettings -Session $Session -Settings $InheritedJsonSettings
}

Function Convert-PuTTYSessionJsonToDotNet {
    [CmdletBinding()]
    [OutputType([Object[]])]
    Param(
        [Parameter(Mandatory)]
        [IO.FileInfo[]]$JsonSession
    )

    Begin {
        $DotNetSessions = [Collections.Generic.List[PuTTYSession]]::new()

        $WriteProgressParams = @{
            Activity = 'Importing PuTTY sessions'
        }
    }

    Process {
        for ($Index = 0; $Index -lt $JsonSession.Count; $Index++) {
            $CurrentSession = $JsonSession[$Index]
            $SessionName = $CurrentSession.BaseName
            $SessionPath = $CurrentSession.FullName

            $WriteProgressParams['Status'] = 'Importing from JSON: {0}' -f $SessionName
            $WriteProgressParams['PercentComplete'] = $Index / $JsonSession.Count * 100
            Write-Progress @WriteProgressParams

            try {
                $JsonContent = Get-Content -LiteralPath $SessionPath -Raw -ErrorAction Stop
                $JsonSettings = $JsonContent | ConvertFrom-Json -ErrorAction Stop
            } catch {
                Write-Error -Message $_
                continue
            }

            $DotNetSession = [PuTTYSession]::new($SessionName, $SessionPath)

            if ($JsonSettings.PSObject.Properties['inherits']) {
                $DotNetSession.Inherits = $JsonSettings.inherits
                $JsonSettings.PSObject.Properties.Remove('inherits')

                foreach ($InheritedJsonSession in $DotNetSession.Inherits) {
                    Add-PuTTYSessionJsonInherit -Session $DotNetSession -InheritedSessionName $InheritedJsonSession
                }
            }

            Merge-PuTTYSettings -Session $DotNetSession -Settings $JsonSettings
            $DotNetSessions.Add($DotNetSession)
        }
    }

    End {
        Write-Progress @WriteProgressParams -Completed
        return , $DotNetSessions
    }
}

Function Export-PuTTYSessionToJson {
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession[]]$Session,

        [Parameter(Mandatory)]
        [String]$Path,

        [Switch]$Force
    )

    Begin {
        try {
            $SessionDir = Get-Item -Path $Path -Force -ErrorAction Stop
        } catch {
            throw $_
        }

        if ($SessionDir -isnot [IO.DirectoryInfo]) {
            throw 'Expected a directory path but received: {0}' -f $SessionDir.GetType().Name
        }

        $OutFileParams = @{
            ErrorAction = 'Stop'
        }

        if (!$Force) {
            $OutFileParams['NoClobber'] = $true
        }

        $WriteProgressParams = @{
            Activity = 'Exporting PuTTY sessions'
        }
    }

    Process {
        for ($Index = 0; $Index -lt $Session.Count; $Index++) {
            $CurrentSession = $Session[$Index]
            $SessionName = $CurrentSession.Name

            $WriteProgressParams['Status'] = 'Exporting to JSON: {0}' -f $SessionName
            $WriteProgressParams['PercentComplete'] = $Index / $Session.Count * 100
            Write-Progress @WriteProgressParams

            $SessionFile = '{0}.json' -f $SessionName
            $SessionPath = Join-Path -Path $SessionDir.FullName -ChildPath $SessionFile
            $SessionJson = $CurrentSession.Settings | ConvertTo-Json -Depth 10 -ErrorAction Stop

            # Format the JSON ourselves under PowerShell 5.x as the built-in
            # formatting by ConvertTo-Json is very broken.
            if ($PSVersionTable.PSVersion.Major -eq 5) {
                $SessionJson = $SessionJson | Format-Json
            }

            # Save the file using UTF-8 with no BOM. We can't use Out-File with
            # -Encoding as there's no "utf8NoBOM" option under PowerShell 5.x.
            $UTF8EncodingNoBom = [Text.UTF8Encoding]::new($false)
            [IO.File]::WriteAllLines($SessionPath, $SessionJson, $UTF8EncodingNoBom)
        }
    }

    End {
        Write-Progress @WriteProgressParams -Completed
    }
}

Function Import-PuTTYSessionFromJson {
    [CmdletBinding()]
    [OutputType([Void], [PSCustomObject[]])]
    Param(
        [Parameter(Mandatory)]
        [String]$Path,

        [String]$Filter,
        [Switch]$Recurse
    )

    try {
        $SessionPath = Get-Item -Path $Path -Force -ErrorAction Stop
    } catch {
        throw $_
    }

    if ($SessionPath -is [IO.FileInfo]) {
        if ($SessionPath.Extension -In $JsonValidExts) {
            $JsonSessions = @($SessionPath)
        } else {
            throw 'Provided path is not a JSON file: {0}' -f $Path
        }
    } elseif ($SessionPath -is [IO.DirectoryInfo]) {
        Write-Debug -Message ('Enumerating JSON sessions at path: {0}' -f $Path)
        $JsonSessions = @(Get-ChildItem -Path $Path -File -Recurse:$Recurse | Where-Object Extension -In $JsonValidExts)

        if ($JsonSessions.Count -eq 0) {
            throw 'No JSON sessions found at path: {0}' -f $Path
        }
    } else {
        throw 'Expected a filesystem path but received: {0}' -f $SessionPath.GetType().Name
    }

    if ($Filter) {
        Write-Debug -Message 'Applying sessions filter ...'
        $JsonSessions = @($JsonSessions | Where-Object BaseName -Like $Filter)

        if ($JsonSessions.Count -eq 0) {
            Write-Error -Message ('No JSON sessions match filter: {0}' -f $Filter)
        }
    }

    Write-Debug -Message 'Converting JSON sessions to .NET objects ...'
    $DotNetSessions = Convert-PuTTYSessionJsonToDotNet -JsonSession $JsonSessions

    return $DotNetSessions.ToArray()
}

#endregion

#region Registry sessions

Function Convert-PuTTYSessionRegistryToDotNet {
    [CmdletBinding()]
    [OutputType([Object[]])]
    Param(
        [Parameter(Mandatory)]
        [Microsoft.Win32.RegistryKey[]]$RegSession,

        [Switch]$ExcludeDefault
    )

    Begin {
        $DotNetSessions = [Collections.Generic.List[PuTTYSession]]::new()

        $WriteProgressParams = @{
            Activity = 'Importing PuTTY sessions'
        }
    }

    Process {
        for ($Index = 0; $Index -lt $RegSession.Count; $Index++) {
            $CurrentSession = $RegSession[$Index]
            $SessionName = ConvertFrom-PuTTYEscapedRegistrySessionKey -SessionName $CurrentSession.PSChildName

            $WriteProgressParams['Status'] = 'Importing from registry: {0}' -f $SessionName
            $WriteProgressParams['PercentComplete'] = $Index / $RegSession.Count * 100
            Write-Progress @WriteProgressParams

            $RegSettings = $CurrentSession.GetValueNames()
            if ($RegSettings.Count -eq 0) {
                Write-Warning -Message ('[{0}] Skipping registry session with no settings.' -f $SessionName)
                continue
            }

            $DotNetSession = [PuTTYSession]::new($SessionName, $CurrentSession.Name.Replace('HKEY_CURRENT_USER\', 'HKCU:\'))

            foreach ($RegSetting in ($CurrentSession.GetValueNames() | Sort-Object)) {
                if ($CfgData.Registry.ContainsKey($RegSetting)) {
                    $SettingData = $CfgData.Registry[$RegSetting]
                } else {
                    if ($RegSetting -notin $RegIgnoredSettings) {
                        Write-Warning -Message ('[{0}] Ignoring unknown registry setting: {1}' -f $SessionName, $RegSetting)
                    }
                    continue
                }

                $DotNetSettingValue = Convert-PuTTYSettingRegistryToDotNet -RegSession $CurrentSession -SettingData $SettingData -ExcludeDefault:$ExcludeDefault
                if ($null -ne $DotNetSettingValue) {
                    Add-PuTTYSetting -Session $DotNetSession -SettingData $SettingData -Value $DotNetSettingValue
                }
            }

            # The default .NET types used for values retrieved from the
            # registry can differ from those used for deserialized JSON (e.g.
            # Int32 for registry DWord versus Int64 for JSON integer). Perform
            # a roundtrip (de)serialisation to JSON to ensure consistency among
            # all .NET types.
            $JsonSettings = $DotNetSession.Settings | ConvertTo-Json -Depth 10 -ErrorAction Stop
            $DotNetSession.Settings = $JsonSettings | ConvertFrom-Json -ErrorAction Stop

            $DotNetSessions.Add($DotNetSession)
        }
    }

    End {
        Write-Progress @WriteProgressParams -Completed
        return , $DotNetSessions
    }
}

Function Convert-PuTTYSettingRegistryToDotNet {
    [CmdletBinding()]
    [OutputType([Void], [Boolean], [Int], [String], [Object[]])]
    Param(
        [Parameter(Mandatory)]
        [Microsoft.Win32.RegistryKey]$RegSession,

        [Parameter(Mandatory)]
        [PSCustomObject]$SettingData,

        [Switch]$ExcludeDefault
    )

    $SessionName = ConvertFrom-PuTTYEscapedRegistrySessionKey -SessionName $RegSession.PSChildName

    $RegSettingName = $SettingData.reg.name
    $RegSettingType = $RegSession.GetValueKind($RegSettingName)
    if ($RegSettingType -ne $SettingData.reg.type) {
        Write-Error -Message ('[{0}] Registry setting {1} has type "{2}" but expected: "{3}"' -f $SessionName, $RegSettingName, $RegSettingType, $SettingData.reg.type)
        return
    }

    $RegSettingValue = $RegSession.GetValue($RegSettingName)
    if ($ExcludeDefault -and $RegSettingValue -eq $SettingData.reg.default) {
        return
    }

    $JsonSettingType = $SettingData.json.type
    $SettingIsEnumType = $SettingData.PSObject.Properties.Name -contains 'enum'

    switch ($RegSettingType) {
        'DWord' {
            switch ($JsonSettingType) {
                'integer' {
                    if (!$SettingIsEnumType) { return $RegSettingValue }

                    $EnumName = Find-EnumName -Enum $SettingData.enum -Value $RegSettingValue
                    if ($EnumName) { return [Int]$EnumName }
                    Write-Error -Message ('[{0}] Registry setting {1} has unknown enumeration value: {2}' -f $SessionName, $RegSettingName, $RegSettingValue)
                }

                'boolean' {
                    if ($RegSettingValue -eq 0 -or $RegSettingValue -eq 1) { return [Boolean]$RegSettingValue }
                    Write-Error -Message ('[{0}] Registry setting {1} has invalid value for boolean type: {2}' -f $SessionName, $RegSettingName, $RegSettingValue)
                }

                'string' {
                    $EnumName = Find-EnumName -Enum $SettingData.enum -Value $RegSettingValue
                    if ($EnumName) { return $EnumName }
                    Write-Error -Message ('[{0}] Registry setting {1} has unknown enumeration value: {2}' -f $SessionName, $RegSettingName, $RegSettingValue)
                }

                Default { throw 'Unexpected JSON type: {0}' -f $JsonSettingType }
            }
        }

        'String' {
            switch ($JsonSettingType) {
                'array' {
                    if ($RegSettingValue -ne [String]::Empty) {
                        return , $RegSettingValue.Split(',')
                    }
                    return , @()
                }

                'string' {
                    if (!$SettingIsEnumType) { return $RegSettingValue }

                    $EnumName = Find-EnumName -Enum $SettingData.enum -Value $RegSettingValue
                    if ($EnumName) { return $EnumName }
                    Write-Error -Message ('[{0}] Registry setting {1} has unknown enumeration value: {2}' -f $SessionName, $RegSettingName, $RegSettingValue)
                }

                Default { throw 'Unexpected JSON type: {0}' -f $JsonSettingType }
            }
        }

        Default { throw 'Unexpected registry type: {0}' -f $RegSettingType }
    }

    return
}

Function Convert-PuTTYSettingsDotNetToRegistry {
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession]$Session,

        [Parameter(Mandatory)]
        [Hashtable]$RegSettings,

        [PSCustomObject]$Settings,
        [String]$CurrentPath = '/'
    )

    if (!$Settings) {
        $Settings = $Session.Settings
    }

    foreach ($Property in $Settings.PSObject.Properties) {
        if ($Property.MemberType -ne 'NoteProperty') {
            throw '[{0}] Unexpected member type at path "{1}" of settings object: {2}' -f $Session.Name, $CurrentPath, $Property.MemberType
        }

        $SettingName = $Property.Name
        if ($CurrentPath -eq '/' -and $SettingName -eq '$schema') {
            continue
        }

        $Setting = $Settings.$SettingName
        $SettingType = $Setting.GetType().Name

        if ($CurrentPath.EndsWith('/')) {
            $SettingPath = '{0}{1}' -f $CurrentPath, $SettingName
        } else {
            $SettingPath = '{0}/{1}' -f $CurrentPath, $SettingName
        }

        if ($SettingType -eq 'PSCustomObject') {
            Convert-PuTTYSettingsDotNetToRegistry -Session $Session -RegSettings $RegSettings -Settings $Setting -CurrentPath $SettingPath
            continue
        }

        if (!$CfgData.Json.ContainsKey($SettingPath)) {
            Write-Warning -Message ('[{0}] Ignoring unknown JSON setting: {1}' -f $Session.Name, $SettingPath)
            continue
        }

        $SettingData = $CfgData.Json[$SettingPath]

        if ($SettingType -eq 'Object[]') {
            $RegSettings[$SettingData.reg.name] = [String]::Join(',', $Setting)
            continue
        }

        if ($SettingData.PSObject.Properties.Name -contains 'enum') {
            $Setting = $SettingData.enum.$Setting
        }

        $RegSettings[$SettingData.reg.name] = $Setting
    }
}

Function Export-PuTTYSessionToRegistry {
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession[]]$Session,

        [Parameter(Mandatory)]
        [String]$Defaults,

        [Switch]$Force
    )

    Begin {
        try {
            Write-Verbose -Message ('Loading defaults from PuTTY v{0} ...' -f $Defaults)
            $DefaultsPath = Join-Path -Path $PSScriptRoot -ChildPath ('defaults\Default Settings - v{0}.json' -f $Defaults)
            $DefaultsFile = Get-Item -Path $DefaultsPath -ErrorAction Stop
            $DefaultSettings = Import-PuTTYSession -Path $DefaultsFile -Verbose:$false

            if (!(Test-Path -Path $RegSessionsPath -PathType Container)) {
                Write-Debug -Message ('Creating saved sessions registry key: {0}' -f $RegSessionsPath)
                $null = New-Item -Path $RegSessionsPath -Force -ErrorAction Stop
            }
        } catch {
            throw $_
        }

        $WriteProgressParams = @{
            Activity = 'Exporting PuTTY sessions'
        }
    }

    Process {
        for ($Index = 0; $Index -lt $Session.Count; $Index++) {
            $CurrentSession = $Session[$Index]
            $SessionName = $CurrentSession.Name

            $WriteProgressParams['Status'] = 'Exporting to registry: {0}' -f $SessionName
            $WriteProgressParams['PercentComplete'] = $Index / $Session.Count * 100
            Write-Progress @WriteProgressParams

            $RegSession = [PuTTYSession]::new($SessionName, $CurrentSession.Origin)
            Merge-PuTTYSettings -Session $RegSession -Settings $DefaultSettings.Settings
            Merge-PuTTYSettings -Session $RegSession -Settings $CurrentSession.Settings

            $RegSettings = @{}
            Convert-PuTTYSettingsDotNetToRegistry -Session $RegSession -RegSettings $RegSettings

            Set-PuTTYSessionRegistry -Session $RegSession -RegSettings $RegSettings
        }
    }

    End {
        Write-Progress @WriteProgressParams -Completed
    }
}

Function Import-PuTTYSessionFromRegistry {
    [CmdletBinding()]
    [OutputType([Void], [PSCustomObject[]])]
    Param(
        [Switch]$ExcludeDefault,
        [String]$Filter
    )

    Write-Debug -Message 'Enumerating registry sessions ...'
    try {
        $RegSessions = Get-ChildItem -Path $RegSessionsPath -ErrorAction Stop
    } catch [Management.Automation.ItemNotFoundException] {
        Write-Error -Message ('Saved sessions registry key does not exist: {0}' -f $RegSessionsPath)
        return
    }

    if ($RegSessions.Count -eq 0) {
        Write-Warning -Message 'No saved sessions found in the registry.'
        return
    }

    if ($Filter) {
        Write-Debug -Message 'Applying sessions filter ...'
        $RegSessions = @($RegSessions | Where-Object PSChildName -Like $Filter)

        if ($RegSessions.Count -eq 0) {
            Write-Error -Message ('No registry sessions match filter: {0}' -f $Filter)
            return
        }
    }

    Write-Debug -Message 'Converting registry sessions to .NET objects ...'
    $DotNetSessions = Convert-PuTTYSessionRegistryToDotNet -RegSession $RegSessions -ExcludeDefault:$ExcludeDefault

    return $DotNetSessions.ToArray()
}

Function Set-PuTTYSessionRegistry {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType([Void])]
    Param(
        [Parameter(Mandatory)]
        [PuTTYSession]$Session,

        [Parameter(Mandatory)]
        [Hashtable]$RegSettings
    )

    $RegSessionName = ConvertTo-PuTTYEscapedRegistrySessionKey -SessionName $Session.Name
    $RegSessionPath = Join-Path -Path $RegSessionsPath -ChildPath $RegSessionName

    try {
        $null = Get-Item -Path $RegSessionPath -ErrorAction Stop
        if (!$Force) {
            Write-Warning -Message ('Skipping existing registry session: {0}' -f $Session.Name)
            return
        }
    } catch [Management.Automation.ItemNotFoundException] {
        $null = New-Item -Path $RegSessionsPath -Name $RegSessionName
    }

    foreach ($RegSettingName in $RegSettings.Keys) {
        $RegSettingType = $CfgData.Registry[$RegSettingName].reg.type
        $RegSettingValue = $RegSettings[$RegSettingName]
        Set-ItemProperty -Path $RegSessionPath -Name $RegSettingName -Type $RegSettingType -Value $RegSettingValue
    }
}

#endregion

#region Utilities

# PowerShell implementation to match PuTTY internal method:
# void unescape_registry_key(const char *in, strbuf *out)
Function ConvertFrom-PuTTYEscapedRegistrySessionKey {
    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory)]
        [String]$SessionName
    )

    $Result = [Text.StringBuilder]::new($SessionName.Length)

    for ($Index = 0; $Index -lt $SessionName.Length) {
        if ($SessionName[$Index] -ne '%') {
            $null = $Result.Append($SessionName[$Index++])
            continue
        }

        $null = $Result.Append([Char][Convert]::ToByte($SessionName.Substring(++$Index, 2), 16))
        $Index += 2
    }

    return $Result.ToString()
}

# PowerShell implementation to match PuTTY internal method:
# void escape_registry_key(const char *in, strbuf *out)
Function ConvertTo-PuTTYEscapedRegistrySessionKey {
    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory)]
        [String]$SessionName
    )

    $Result = [Text.StringBuilder]::new($SessionName.Length, 1024)

    $FirstChar = $true
    foreach ($Char in $SessionName.ToCharArray()) {
        if ($Char -le ' ' -or $Char -eq '%' -or $Char -eq '*' -or $Char -eq '?' -or $Char -eq '\' -or $Char -gt '~' -or ($Char -eq '.' -and $FirstChar)) {
            $null = $Result.Append('%')
            $null = $Result.Append('{0:X2}' -f [System.Text.Encoding]::ASCII.GetBytes($Char)[0])
        } else {
            $null = $Result.Append($Char)
        }
        $FirstChar = $false
    }

    return $Result.ToString()
}

Function Find-EnumName {
    [CmdletBinding()]
    [OutputType([String])]
    Param(
        [Parameter(Mandatory)]
        [PSCustomObject]$Enum,

        [Parameter(Mandatory)]
        [Object]$Value
    )

    foreach ($Name in $Enum.PSObject.Properties.Name) {
        if ($Enum.$Name -eq $Value) {
            return $Name
        }
    }
}

# Modified version of:
# https://stackoverflow.com/a/56324939
#
# This is only necessary under PowerShell 5.x as its ConvertTo-Json cmdlet does
# not perform correct indentation.
Function Format-Json {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [String[]]$InputObject,

        [ValidateRange(1, 8)]
        [Int]$IndentSize = 2
    )

    Begin {
        $Indent = [String]::Empty
        $IndentLevel = 0

        $RegexNotQuoted = '(?=([^"]*"[^"]*")*[^"]*$)'
        $RegexIncreaseIndent = "[\{\[]$RegexNotQuoted"
        $RegexDecreaseIndent = "[}\]]$RegexNotQuoted"
        $RegexColonSpace = ":\s+$RegexNotQuoted"

        $FormattedJson = [Collections.Generic.List[String]]::new()
    }

    Process {
        foreach ($Line in ($InputObject -split '\r?\n')) {
            # If the line contains a ] or } character, decrement the
            # indentation level unless the character is inside quotes.
            if ($Line -match $RegexDecreaseIndent) {
                --$IndentLevel

                if ($IndentLevel -lt 0) {
                    Write-Warning -Message 'Encountered negative indentation level.'
                    $IndentLevel = 0
                }

                $Indent = ' ' * $IndentLevel * $IndentSize
            }

            # Replace all colon-space combinations unless they are quoted
            $Line = $Indent + ($Line.TrimStart() -replace $RegexColonSpace, ': ')

            # If the line contains a [ or { character, increment the
            # indentation level unless the character is inside quotes.
            if ($Line -match $RegexIncreaseIndent) {
                ++$IndentLevel
                $Indent = ' ' * $IndentLevel * $IndentSize
            }

            $FormattedJson.Add($Line)
        }
    }

    End {
        return $FormattedJson -Join [Environment]::NewLine
    }

}

#endregion