PsLogMessage.psm1


function Get-ParamValueString {

    param ([string[]]$String)

    ForEach ($CurrentString in $String) {

        if ($CurrentString.Contains("'")) {
            "`"$CurrentString`""
        } else {
            "'$CurrentString'"
        }

    }

}
function ConvertTo-DnsFqdn {

    # Output the results of a DNS lookup to the default DNS server for the specified
    # Wrapper for [System.Net.Dns]::GetHostByName([string]$ComputerName)

    param (

        # DNS or NetBIOS hostname whose DNS FQDN to lookup
        [Parameter(Mandatory)]
        [string]$ComputerName,

        # Update the ThisFqdn cache instead of returning the Fqdn
        [switch]$ThisFqdn,

        # In-process cache to reduce calls to other processes or disk, and store repetitive parameters for better readability of code and logs
        [Parameter(Mandatory)]
        [ref]$Cache

    )

    Write-LogMsg -Text "[System.Net.Dns]::GetHostByName('$ComputerName')" -Cache $Cache
    $Fqdn = [System.Net.Dns]::GetHostByName($ComputerName).HostName
    if ( $ThisFqdn ) { $Cache.Value['ThisFqdn'].Value = $Fqdn }
    return $Fqdn

}
function ConvertTo-PSCodeString {

    <#
        .SYNOPSIS
            Convert an object or array of objects into a code string which could be converted into a ScriptBlock with valid PowerShell syntax
        .DESCRIPTION
            Originally used for hashtables and arrays
        .INPUTS
            $InputObject parameter
        .OUTPUTS
            [System.String] Resulting PowerShell code
    #>

    [OutputType([System.String])]
    [CmdletBinding()]

    param (

        # Object to convert to a PowerShell code string
        $InputObject

    )

    if ($InputObject) {

        switch ($InputObject.GetType().FullName) {

            'System.Collections.Hashtable' {
                $Strings = ForEach ($OriginalKey in $InputObject.Keys) {
                    $Key = ConvertTo-PSCodeString -InputObject $OriginalKey
                    $Value = ConvertTo-PSCodeString -InputObject $InputObject[$OriginalKey]
                    "$Key=$Value"
                }
                "@{$($Strings -join ';')}"
                break
            }

            'System.Object[]' {
                $Strings = ForEach ($Object in $InputObject) {
                    ConvertTo-PSCodeString -InputObject $Object
                }
                "@($($Strings -join ','))"
                break
            }

            'System.String' {
                if ($InputObject.Contains("'")) {
                    $Value = $InputObject.Replace('"', '`"')
                    "`"$Value`""
                } else {
                    "'$InputObject'"
                    break
                }
            }

            'System.Collections.Specialized.OrderedDictionary' {
                $Strings = ForEach ($OriginalKey in $InputObject.Keys) {
                    $Key = ConvertTo-PSCodeString -InputObject $OriginalKey
                    $Value = ConvertTo-PSCodeString -InputObject $InputObject[$OriginalKey]
                    "$Key=$Value"
                }
                "@{$($Strings -join ';')}"
                break
            }

            default { "$InputObject" }

        }

    } else {
        "`$null"
    }

}
function Export-LogCsv {

    <#
        .SYNOPSIS
            Export a hashtable of log message objects to a CSV file.
        .DESCRIPTION
            Sort the log message objects by their Timestamp property.
            Export the log message objects to a CSV file.
            Write the file path to the Information stream.
        .INPUTS
        [System.String]$LogFile parameter
        .OUTPUTS
        None. The value of $LogFile is written to the Information stream.
    #>

    [OutputType([System.String])]
    [CmdletBinding()]
    param(

        # Log file to create
        [string]$LogFile,

        # Log messages which have not yet been written to disk
        [Parameter(Mandatory)]
        [ref]$Buffer,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # ID of the parent progress bar under which to show progres
        [int]$ProgressParentId

    )

    $Log = @{
        Buffer       = $Buffer
        ThisHostname = $ThisHostname
        Type         = $DebugOutputStream
        WhoAmI       = $WhoAmI
    }

    Write-LogMsg @Log -Text "`$Buffer.Values | Sort-Object -Property Timestamp | Export-Csv -Delimiter '$('`t')' -NoTypeInformation -LiteralPath '$LogFile'"

    $Buffer.Value.GetEnumerator() |
    Export-Csv -Delimiter "`t" -NoTypeInformation -LiteralPath $LogFile

    # Write the full path of the log file to the Information stream
    Write-Information $LogFile

}
function Get-CurrentHostName {
    # Future function to universally retrieve hostname using various methods (in order of preference):
    # hostname.exe
    # $env:hostname
    # CIM
    # other?
}
function Get-CurrentWhoAmI {

    # Output the results of whoami.exe after editing them to correct capitalization of both the hostname and the account name

    # whoami.exe returns lowercase but we want to honor the correct capitalization

    # Correct capitalization is returned from $ENV:USERNAME

    param (

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [string]$ThisHostName = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [string]$WhoAmI = (whoami.EXE)

    )

    $WhoAmI -replace "^$ThisHostname\\", "$ThisHostname\" -replace "$ENV:USERNAME", $ENV:USERNAME

}
function Get-ParamStringMap {

    return @{

        'System.Collections.Hashtable'                 = {
            param ($ParamName, $ParamValue)
            "`$$ParamName"
        }

        'System.Collections.Hashtable+SyncHashtable'   = {
            param ($ParamName, $ParamValue)
            "`$$ParamName"
        }

        'System.Int32'                                 = {
            param ($ParamName, $ParamValue)
            "($ParamValue)" # paren to encapsulate negative values
        }

        'System.UInt16'                                = {
            param ($ParamName, $ParamValue)
            "($ParamValue)" # paren to encapsulate negative values
        }

        'System.Object[]'                              = {
            param ($ParamName, $ParamValue)
            "@('$($ParamValue -join "','")')"
        }

        'System.String[]'                              = {
            param ($ParamName, $ParamValue)
            $NewValues = Get-ParamValueString -String $ParamValue
            "@($($NewValues -join ','))"
        }

        'System.Management.Automation.PSCustomObject'  = {
            param ($ParamName, $ParamValue)
            "[PSCustomObject]$ParamValue"
        }

        'System.String'                                = {
            param ($ParamName, $ParamValue)
            Get-ParamValueString -String $ParamValue
        }

        'System.Boolean'                               = {
            param ($ParamName, $ParamValue)
            "`$$ParamValue"
        }

        'System.Management.Automation.SwitchParameter' = {
            param ($ParamName, $ParamValue)
            "`$$ParamValue"
        }

        'System.Management.Automation.ScriptBlock'     = {
            param ($ParamName, $ParamValue)
            "{$ParamValue}"
        }

    }

}
function New-DatedSubfolder {
    # Creates a folder structure with a folder for each year and month
    # Then it creates one timestamped folder inside the appropriate month
    # This folder is intended to be used to store output from a single execution of a script
    param (
        [parameter(Mandatory)]
        [string]$Root,

        # A suffix to append to the folder name
        [string]$Suffix
    )
    $Year = Get-Date -Format 'yyyy'
    $Month = Get-Date -Format 'MM'
    $Timestamp = (Get-Date -Format s) -replace ':', '-'

    $NewDir = "$Root\$Year\$Month\$Timestamp$Suffix"

    $null = New-Item -ItemType Directory -Path $NewDir -ErrorAction SilentlyContinue
    Write-Output $NewDir
}
function Write-LogMsg {

    <#
    .SYNOPSIS
        Prepend a prefix to a log message, write the message to an output stream, and write the message to a text file.
        Writes a message to a log file and/or PowerShell output stream
    .DESCRIPTION
        Prepends the log message with:
            a current timestamp
            the current hostname
            the current username
            the current command (function or file name)
            the current location (line number in the code)
 
        Tab-delimits these fields for a compromise between readability and parseability
 
        Adds the log message to a ConcurrentQueue which was passed to the $Buffer parameter
 
        Optionally writes the message to a log file
 
        Optionally writes the message to a PowerShell output stream
    .INPUTS
    [System.String[]]$Text parameter
    .OUTPUTS
    [System.String] Resulting log line, returned if the -PassThru or -Type Output parameters were used
    #>


    [OutputType([System.String])]
    [CmdletBinding()]

    param(

        # Message to log
        [Parameter(Position = 0, ValueFromPipeline)]
        [string[]]$Text,

        # Suffix to append to the end of the string
        [string]$Suffix,

        # Add a prefix to the message including the date, hostname, current user, and info about the current call stack
        [bool]$AddPrefix = $true,

        # Text file to append the log message to
        [string]$LogFile,

        # Output the message to the pipeline
        [bool]$PassThru = $false,

        <#
        Splats for the command at the end of the -Text parameter.
        E.g.
            Write-LogMsg -Text 'Get-Process' -Expand @{Name = 'explorer'}
        Has a resultant $Text value of:
            Get-Process -Name explorer
        #>

        [hashtable[]]$Expand,

        # Output stream to send the message to
        [Parameter(ParameterSetName = 'NoCache')]
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [string]$Type,

        # Hostname to use in the log messages and/or output object
        [Parameter(ParameterSetName = 'NoCache')]
        [string]$ThisHostname,

        # Hostname to use in the log messages and/or output object
        [Parameter(ParameterSetName = 'NoCache')]
        [string]$WhoAmI,

        # Log messages which have not yet been written to disk
        [Parameter(Mandatory, ParameterSetName = 'NoCache')]
        [ref]$Buffer,

        # Used to override key-value pairs in the Expand parameter.
        [Parameter(ParameterSetName = 'NoCache')]
        [hashtable]$ExpandKeyMap,

        [Parameter(ParameterSetName = 'NoCache')]
        [hashtable]$ParamStringMap,

        # In-process cache to reduce calls to other processes or disk, and store repetitive parameters for better readability of code and logs
        [Parameter(Mandatory, ParameterSetName = 'Cache')]
        [ref]$Cache,

        [Parameter(ParameterSetName = 'Cache')]
        [string]$MapKeyName = 'LogEmptyMap'

    )

    begin {

        # This will ensure the message is not written to any PowerShell output streams or log files
        if ($Type -eq 'Silent') { return }

        $Timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.ffffK'
        $OutputToPipeline = $false
        $PSCallStack = Get-PSCallStack
        $Caller = $PSCallStack[1]
        $Location = $Caller.Location
        $Command = $Caller.Command

        if ($PSCmdlet.ParameterSetName -eq 'Cache') {
            [string]$Type = $Cache.Value['LogType'].Value
            [string]$ThisHostname = $Cache.Value['ThisHostname'].Value
            [string]$WhoAmI = $Cache.Value['WhoAmI'].Value
            [ref]$Buffer = $Cache.Value['LogBuffer']
            [hashtable]$ParamStringMap = $Cache.Value['ParamStringMap'].Value
            [hashtable]$ExpandKeyMap = $Cache.Value[$MapKeyName].Value
        } else {

            if (-not $PSBoundParameters.ContainsKey('Type')) {
                $Type = 'Information'
            }

            if (-not $PSBoundParameters.ContainsKey('ThisHostname')) {
                $ThisHostname = HOSTNAME.EXE
            }

            if (-not $PSBoundParameters.ContainsKey('WhoAmI')) {
                $WhoAmI = whoami.EXE
            }

            if (-not $PSBoundParameters.ContainsKey('ExpandKeyMap')) {
                $ExpandKeyMap = @{}
            }

            if (-not $PSBoundParameters.ContainsKey('ParamStringMap')) {
                $ParamStringMap = Get-ParamStringMap
            }

        }

    }

    process {

        ForEach ($String in $Text) {

            ForEach ($Splat in $Expand) {

                ForEach ($ParamName in $Splat.Keys) {

                    $ParamValue = $ExpandKeyMap[$ParamName]

                    if ($null -eq $ParamValue) {

                        $ParamValue = $Splat[$ParamName]

                        if ($null -ne $ParamValue) {

                            $TypeName = $ParamValue.GetType().FullName
                            $ValueScript = $ParamStringMap[$TypeName]

                            if ($ValueScript) {
                                $ParamValue = Invoke-Command -Command $ValueScript -ArgumentList $ParamName, $ParamValue
                            } else {
                                $ParamValue = "'$ParamValue'"
                            }

                        } else {

                            # Hopefully this skips appending this parameter but I'm not sure it will 'continue' in the right ForEach scope due to the nesting
                            continue

                        }

                    }

                    $String = "$String -$ParamName $ParamValue"

                }

            }

            $FullText = "$String$Suffix"

            if ($AddPrefix) {
                # This method is faster than StringBuilder or the -join operator
                $MessageToLog = "$Timestamp`t$ThisHostname`t$WhoAmI`t$Location`t$Command`t$($MyInvocation.ScriptLineNumber)`t$Type`t$FullText"
            } else {
                $MessageToLog = $FullText
            }

            Switch ($Type) {

                # This will ensure the message is added to log files, but not written to any PowerShell output streams
                'Quiet' { break }

                # This one is made-up to correspond with the 'success' contextual class in Bootstrap.
                'Success' { Write-Information "SUCCESS: $MessageToLog" ; break }

                # These represent normal PowerShell output streams
                # The correct number of spaces should be added to maintain proper column alignment
                'Debug' { Write-Debug " $MessageToLog" ; break }
                'Verbose' { Write-Verbose $MessageToLog ; break }
                'Host' { Write-Host "HOST: $MessageToLog" ; break }
                'Warning' { Write-Warning $MessageToLog ; break }
                'Error' { Write-Error $MessageToLog ; break }
                'Output' { $OutputToPipeline = $true ; break }
                default { Write-Information "INFO: $MessageToLog" ; break }

            }

            if ($PSBoundParameters.ContainsKey('LogFile')) {
                $MessageToLog | Out-File $LogFile -Append
            }

            if ($PassThru -or $OutputToPipeline) {
                $MessageToLog
            }

            $Obj = [ordered]@{
                Timestamp = $Timestamp
                Hostname  = $ThisHostname
                WhoAmI    = $WhoAmI
                Location  = $Location
                Command   = $Command
                Line      = $MyInvocation.ScriptLineNumber
                Type      = $Type
                Text      = $FullText
            }

            $null = $Buffer.Value.Enqueue($Obj)

        }

    }

}
<#
# Add any custom C# classes as usable (exported) types
$CSharpFiles = Get-ChildItem -Path "$PSScriptRoot\*.cs"
ForEach ($ThisFile in $CSharpFiles) {
    Add-Type -Path $ThisFile.FullName -ErrorAction Stop
}
#>

Export-ModuleMember -Function @('ConvertTo-DnsFqdn','ConvertTo-PSCodeString','Export-LogCsv','Get-CurrentHostname','Get-CurrentWhoAmI','Get-ParamStringMap','New-DatedSubfolder','Write-LogMsg')