Workoho.Automation.Common/Public/Write-Auto_CsvOutput.ps1

<#
.SYNOPSIS
    Write text in CSV format to output stream

.DESCRIPTION
    This script is used to write text in CSV format to the output stream. It takes an input object and converts it to CSV using the ConvertTo-Csv cmdlet.
    The converted CSV is then written to the output stream, or uploaded to a blob storage if the StorageUri parameter is specified.

.PARAMETER InputObject
    Specifies the object to be converted to CSV.

.PARAMETER ConvertToParam
    Specifies additional parameters to be passed to the ConvertTo-CSV cmdlet.
    NoTypeInformation is set to true by default. If you want to include type information, set NoTypeInformation to false (PS >= 5.1) or IncludeTypeInformation to true (PS >= 6.0).

.PARAMETER BooleanTrueValue
    Specifies the value to be used for boolean true values. Default is '1'.

.PARAMETER BooleanFalseValue
    Specifies the value to be used for boolean false values. Default is '0'.

.PARAMETER NullValue
    Specifies the value to be used for null values. Default is a $null value, which leaves the field empty.
    Note that if you want to have an empty string instead of a null value, you can set NullValue to an empty string ('').
    You may also set NullValue to any other value you want to use for null values, like 'NULL' or 'N/A'.

.PARAMETER Metadata
    Specifies the metadata to append to the CSV file. The metadata is represented as key-value pairs, where keys (prefixed by '#') are placed in the first column, and their corresponding values in the second column.
    This is useful for adding additional information to the CSV file, such as column descriptions or data source information.

    The metadata should be provided as a PSCustomObject. The properties of the PSCustomObject are used as column names in the CSV, and the corresponding values are used as the column values.

.PARAMETER StorageUri
    Specifies the URI of the storage where the CSV file should be uploaded.
    If this parameter is specified, the CSV file will be uploaded to the specified storage.
    If this parameter is not specified, the CSV file will be written to the output stream.

    The URI must be in the format 'https://<storage-account-name>.blob.core.windows.net/<container-name>/<blob-name>.csv' for blob storage
    or 'https://<storage-account-name>.file.core.windows.net/<share-name>/<file-name>.csv' for file storage.

    Please note that the Azure Automation account's managed identity (or your developer account) must have the 'Storage Blob Data Contributor' or
    'Storage File Data SMB Share Contributor' role on the storage account, depending on the storage type.
    Remember that general Owner or Contributor roles are NOT sufficient for uploading blobs to a storage account.

    For information compliance reasons, consider to configure retention policies for the storage account and the blob container if you are uploading sensitive data like personal identifiable information (PII).

.PARAMETER FileEncoding
    Specifies the encoding to be used when writing the CSV file. Default is 'utf8BOM'.
    Possible values are 'ansi', 'ascii', 'bigendianunicode', 'bigendianutf32', 'oem', 'unicode', 'utf7', 'utf8', 'utf8BOM', 'utf8NoBOM', 'utf32'.

    Note that utf8BOM stands for UTF-8 with Byte Order Mark (BOM) and utf8NoBOM stands for UTF-8 without BOM.
    A BOM helps to identify the encoding of a file, like for Microsoft Excel. However, some applications may not support BOMs, or use other techniques to identify the encoding.

.PARAMETER FileNewLine
    Specifies the newline character to be used when writing the CSV file. Default is "`r`n" to use Windows-style line endings (CRLF).

.EXAMPLE
    PS> Write-Auto_CsvOutput -InputObject $data
    This example converts the $data object to CSV, and writes it to the output stream.

.EXAMPLE
    PS> Write-Auto_CsvOutput -InputObject $data -StorageUri 'https://mystorageaccount.blob.core.windows.net/mycontainer/myblob.csv?sastoken'
    This example converts the $data object to CSV, and uploads it to the specified blob storage.

.EXAMPLE
    PS> Write-Auto_CsvOutput -InputObject $data -StorageUri 'https://mystorageaccount.file.core.windows.net/myshare/myfile.csv?sastoken'
    This example converts the $data object to CSV, and uploads it to the specified file storage.
#>


function Write-Auto_CsvOutput {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory = $true)]
        $InputObject,
        [pscustomobject] $Metadata,

        [hashtable] $ConvertToParam,
        [string] $BooleanTrueValue = '1',
        [string] $BooleanFalseValue = '0',
        [string] $NullValue,
        [string] $StorageUri,
        [string] $FileEncoding = 'utf8BOM',
        [string] $FileNewLine = "`r`n"
    )

    Write-Auto_FunctionBegin $MyInvocation -OnceOnly

    $params = if ($ConvertToParam) { $ConvertToParam.Clone() } else { @{} }
    if ([string]::IsNullOrEmpty($params.NoTypeInformation) -and ([string]::IsNullOrEmpty($params.IncludeTypeInformation) -or $params.IncludeTypeInformation -eq $false)) {
        $params.Remove('IncludeTypeInformation')
        $params.NoTypeInformation = $true # use NoTypeInformation for PowerShell 5.1 backwards compatibility
    }
    if ($params.UseCulture -eq $true) {
        $params.Delimiter = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.ListSeparator
        $params.Remove('UseCulture')
    }
    elseif ([string]::IsNullOrEmpty($params.Delimiter)) {
        $params.Delimiter = ','
    }
    if ([string]::IsNullOrEmpty($params.ErrorAction)) {
        $params.ErrorAction = 'Stop'
    }

    function ConvertTo-CsvFriendlyObject {
        [CmdletBinding()]
        param (
            [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
            [psobject]$InputObject,
            [string]$TrueString = "True",
            [string]$FalseString = "False",
            [string]$ArraySeparator = ", ", # Default is ", " for better readability
            [AllowNull()][string]$NullString = $null, # Default is $null to leave fields empty
            [int]$MaxDepth = 2  # Limit recursion depth to 2
        )

        process {
            if ($null -eq $InputObject) {
                Write-Debug "Skipping null input object"
                return
            }

            function Convert-Value {
                param (
                    [object]$Value,
                    [int]$CurrentDepth
                )

                if ($CurrentDepth -gt $MaxDepth) {
                    if ($Value -is [System.Collections.IEnumerable] -or $Value -is [System.Collections.IDictionary]) {
                        Write-Debug "Converting complex nested structure to JSON"
                        return $Value | ConvertTo-Json -Compress -Depth 10
                    }
                    return $Value
                }

                if ($Value -is [string]) {
                    Write-Debug "Using string value"
                    return $Value
                }
                elseif ($Value -is [PSObject]) {
                    Write-Debug "Not converting PSObject value"
                    return $Value.GetType().Name
                }
                elseif ($Value -is [DateTime]) {
                    Write-Debug "Converting DateTime value"
                    return $Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
                }
                elseif ($Value -is [bool]) {
                    Write-Debug "Converting Boolean value"
                    return $(if ($Value) { $TrueString } else { $FalseString })
                }
                elseif ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
                    Write-Debug "Converting Array value"
                    return Convert-Array -Array $Value -CurrentDepth ($CurrentDepth + 1)
                }
                elseif ($Value -is [System.Collections.Hashtable]) {
                    Write-Debug "Converting Hashtable value"
                    Convert-Hashtable -Hashtable $Value -CurrentDepth ($CurrentDepth + 1)
                    return [PSCustomObject]$Value
                }
                elseif ($null -eq $Value) {
                    if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('NullString')) {
                        Write-Debug "Converting Null value"
                        return $NullString
                    }
                }
                else {
                    Write-Debug "Converting value to string"
                    try {
                        return $Value.ToString()
                    }
                    catch {
                        Write-Debug "Unable to convert value to string"
                        return $Value.GetType().Name
                    }
                }
                return $Value
            }

            function Convert-Hashtable {
                param (
                    [hashtable]$Hashtable,
                    [int]$CurrentDepth
                )

                $keys = @($Hashtable.Keys)  # Create a copy of the keys to avoid modification issues
                foreach ($key in $keys) {
                    $Hashtable[$key] = Convert-Value -Value $Hashtable[$key] -CurrentDepth $CurrentDepth
                }
            }

            function Convert-Array {
                param (
                    [array]$Array,
                    [int]$CurrentDepth
                )

                $processedArray = [System.Collections.ArrayList]::new()
                foreach ($item in $Array) {
                    $processedArray.Add((Convert-Value -Value $item -CurrentDepth $CurrentDepth))
                }

                # Escape occurrences of the trimmed ArraySeparator in array elements and join
                return ($processedArray.ForEach({
                            if ($_ -is [string]) {
                                $_.Replace($ArraySeparator.Trim(), "\" + $ArraySeparator.Trim())
                            }
                            else {
                                $_
                            }
                        })) -join $ArraySeparator
            }

            function Convert-Object {
                param (
                    [PSCustomObject]$Obj,
                    [int]$CurrentDepth
                )

                if ($Obj -is [System.Collections.IDictionary]) {
                    Convert-Hashtable -Hashtable $Obj -CurrentDepth $CurrentDepth
                    $Obj = [PSCustomObject]$Obj  # Convert hashtable to PSCustomObject
                }
                elseif ($Obj -is [PSObject]) {
                    $properties = @($Obj.PSObject.Properties)  # Create a copy of the properties to avoid modification issues
                    foreach ($property in $properties) {
                        if ($property.IsSettable) {
                            if ($property.Value -is [PSCustomObject]) {
                                Write-Debug "Flattening nested object property '$($property.Name)'"
                                foreach ($prop in $_.$($property.Name).PSObject.Properties) {
                                    $_ | Add-Member -MemberType NoteProperty -Name "$($property.Name)__$($prop.Name)" -Value (Convert-Value -Value $prop.Value -CurrentDepth $CurrentDepth) -Force
                                }
                                $Obj.PSObject.Properties.Remove($property.Name)
                            }
                            else {
                                $Obj.$($property.Name) = Convert-Value -Value $property.Value -CurrentDepth $CurrentDepth
                            }
                        }
                    }
                }
                elseif ($Obj -is [System.Collections.IEnumerable] -and -not ($Obj -is [string])) {
                    foreach ($item in $Obj) {
                        Convert-Object -Obj $item -CurrentDepth ($CurrentDepth + 1)
                    }
                }
                elseif ($Obj -is [System.Object]) {
                    $Obj = [PSCustomObject]$Obj
                }

                return $Obj
            }

            if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) {
                $processedItems = [System.Collections.ArrayList]::new()
                foreach ($item in $InputObject) {
                    if ($item -is [PSObject] -or $item -is [System.Collections.IDictionary]) {
                        $processedItems.Add((Convert-Object -Obj $item -CurrentDepth 0))
                    }
                    else {
                        $processedItems.Add($item)
                    }
                }
                $InputObject = $processedItems
            }
            elseif ($InputObject -is [PSObject] -or $InputObject -is [System.Collections.IDictionary]) {
                $InputObject = Convert-Object -Obj $InputObject -CurrentDepth 0
            }
            elseif ($InputObject -is [System.Object]) {
                $InputObject = [PSCustomObject]$InputObject
            }

            Write-Debug "Final processed input object type: $($InputObject.GetType().Name)"
            Write-Debug "Final processed input object: $($InputObject | Out-String)"

            $InputObject
        }
    }

    function ConvertTo-PSCustomObject {
        param (
            [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
            [psobject]$InputObject
        )

        process {
            if ($null -eq $InputObject) {
                Write-Debug "Received a null input, returning null."
                return $null
            }
            elseif ($InputObject -is [System.Collections.IDictionary]) {
                Write-Debug "Converting Hashtable to PSCustomObject"
                return [PSCustomObject]$InputObject
            }
            else {
                Write-Debug "Returning input as is"
                return $InputObject
            }
        }
    }

    try {
        if ($StorageUri) {
            $tempFile = [System.IO.Path]::GetTempFileName()
            $encodingObject = switch ($FileEncoding) {
                "ansi" { [System.Text.Encoding]::Default }
                "ascii" { [System.Text.Encoding]::ASCII }
                "bigendianunicode" { [System.Text.Encoding]::BigEndianUnicode }
                "bigendianutf32" { [System.Text.Encoding]::BigEndianUTF32 }
                "oem" { [System.Text.Encoding]::Default }
                "unicode" { [System.Text.Encoding]::Unicode }
                "utf7" { [System.Text.Encoding]::UTF7 }
                "utf8" { New-Object System.Text.UTF8Encoding($false) }
                "utf8BOM" { New-Object System.Text.UTF8Encoding($true) }
                "utf8NoBOM" { New-Object System.Text.UTF8Encoding($false) }
                "utf32" { [System.Text.Encoding]::UTF32 }
                default { New-Object System.Text.UTF8Encoding($true) }
            }
            $streamWriter = New-Object System.IO.StreamWriter($tempFile, $false, $encodingObject)
            $streamWriter.NewLine = $FileNewLine
            try {
                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('NullValue')) {
                    $InputObject | ConvertTo-PSCustomObject | ConvertTo-CsvFriendlyObject -TrueString $BooleanTrueValue -FalseString $BooleanFalseValue -NullString $NullValue | ConvertTo-Csv @params | & { process { $streamWriter.WriteLine($_) } }
                }
                else {
                    $InputObject | ConvertTo-PSCustomObject | ConvertTo-CsvFriendlyObject -TrueString $BooleanTrueValue -FalseString $BooleanFalseValue | ConvertTo-Csv @params | & { process { $streamWriter.WriteLine($_) } }
                }

                if (
                    $null -ne $Metadata -and
                ($Metadata | Get-Member -MemberType NoteProperty | Measure-Object).Count -gt 0
                ) {
                    $streamWriter.Close()
                    $missingColumnsString = $params.Delimiter * (Get-Content $tempFile -TotalCount 1 | & { process { $_ | ConvertFrom-Csv -Header $_.Split($params.Delimiter) -Delimiter $params.Delimiter } } | Get-Member -MemberType NoteProperty | Measure-Object).Count
                    $streamWriter = New-Object System.IO.StreamWriter($tempFile, $true)
                    $streamWriter.WriteLine('')
                    $Metadata.PSObject.properties | & { process {
                            $key = "# $($_.Name)"
                            $val = $_.Value | & {
                                process {
                                    if ($_ -is [string]) {
                                        $_
                                    }
                                    elseif ($_ -is [array]) {
                                        $_ -join ', '
                                    }
                                    elseif ($_ -is [DateTime]) {
                                        [DateTime]::Parse($_).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
                                    }
                                    elseif ($_.PSObject.Methods.Name -contains 'ToString') {
                                        $_.ToString()
                                    }
                                    else {
                                        return
                                    }
                                }
                            }

                            if (
                                $null -eq $params.UseQuotes -or
                                $params.UseQuotes -eq 'Always' -or
                                (
                                    $params.UseQuotes -eq 'AsNeeded' -and
                                    (
                                        $key -like "*$($params.Delimiter)*" -or
                                        $key -like '*"*' -or
                                        $key -match "`n|`r"
                                    )
                                )
                            ) {
                                $key = "`"$($key -replace '`"', '`"`"')`""
                            }

                            if (
                                -not [string]::IsNullOrEmpty($val) -and
                                (
                                    $null -eq $params.UseQuotes -or
                                    $params.UseQuotes -eq 'Always' -or
                                    (
                                        $params.UseQuotes -eq 'AsNeeded' -and
                                        (
                                            $val -like "*$($params.Delimiter)*" -or
                                            $val -like '*"*' -or
                                            $val -match "`n|`r"
                                        )
                                    )
                                )
                            ) {
                                $val = "`"$($val -replace '`"', '`"`"')`""
                            }

                            $streamWriter.WriteLine("$key$($params.Delimiter)$val$missingColumnsString")
                        }
                    }
                }
            }
            finally {
                $streamWriter.Close()
            }

            $uri = [System.Uri] $StorageUri
            $sasToken = if ($uri.Query) { $uri.Query.TrimStart('?') } else { $null }
            $hostParts = if ($uri.Host) { $uri.Host.Split('.') } else { throw 'Invalid storage URI' }
            $storageAccountName = $hostParts[0]
            $storageType = $hostParts[1]
            $pathParts = if ($uri.AbsolutePath) { $uri.AbsolutePath.Split('/') } else { throw 'Invalid storage URI' }
            $containerName = $pathParts[1]
            $filePath = $pathParts[2..($pathParts.Length - 1)] -join '/'
            if ([string]::IsNullOrEmpty($filePath)) { $filePath = [System.IO.Path]::GetFileNameWithoutExtension($tempFile) }
            if (-not $filePath.EndsWith('.csv')) { $filePath += '.csv' }

            Import-Auto_Module @(
                @{ Name = 'Az.Storage'; MinimumVersion = '3.0' }
            ) 1> $null

            if ([string]::IsNullOrEmpty($sasToken)) {
                $context = New-AzStorageContext -StorageAccountName $storageAccountName -UseConnectedAccount -ErrorAction Stop
            }
            else {
                $sasToken = [System.Uri]::UnescapeDataString($sasToken)
                $context = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sasToken -ErrorAction Stop
            }

            if ($storageType -eq 'blob') {
                $params = @{
                    File        = $tempFile
                    Container   = $containerName
                    Blob        = $filePath
                    Context     = $context
                    Force       = $true
                    ErrorAction = 'Stop'
                    Verbose     = $false
                    Debug       = $false
                }
                $null = Set-AzStorageBlobContent @params
            }
            elseif ($storageType -eq 'file') {
                if (-not $context.StorageAccount.Credentials.IsSAS) {
                    Throw "The storage type 'file' requires a SAS token in the StorageUri parameter."
                }
                $params = @{
                    Source      = $tempFile
                    ShareName   = $containerName
                    Path        = $filePath
                    Context     = $context
                    Force       = $true
                    ErrorAction = 'Stop'
                    Verbose     = $false
                    Debug       = $false
                }
                $null = Set-AzStorageFileContent @params
            }
            else {
                throw "Invalid storage type '$storageType'. The storage type must be 'blob' or 'file."
            }

            Write-Output "CSV file uploaded to $($uri.GetLeftPart([System.UriPartial]::Path))"
        }
        else {
            Write-Output $(
                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('NullValue')) {
                    $csv = $InputObject | ConvertTo-PSCustomObject | ConvertTo-CsvFriendlyObject -TrueString $BooleanTrueValue -FalseString $BooleanFalseValue -NullString $NullValue | ConvertTo-Csv @params
                }
                else {
                    $csv = $InputObject | ConvertTo-PSCustomObject | ConvertTo-CsvFriendlyObject -TrueString $BooleanTrueValue -FalseString $BooleanFalseValue | ConvertTo-Csv @params
                }
                $csv

                if (
                    $null -ne $Metadata -and
                ($Metadata | Get-Member -MemberType NoteProperty | Measure-Object).Count -gt 0
                ) {
                    ''
                    $missingColumnsString = $params.Delimiter * ($csv | Select-Object -First 1 | & { process { $_ | ConvertFrom-Csv -Header $_.Split($params.Delimiter) -Delimiter $params.Delimiter } } | Get-Member -MemberType NoteProperty | Measure-Object).Count
                    $Metadata.PSObject.properties | & { process {
                            $key = "# $($_.Name)"
                            $val = $_.Value | & {
                                process {
                                    if ($_ -is [string]) {
                                        $_
                                    }
                                    elseif ($_ -is [array]) {
                                        $_ -join ', '
                                    }
                                    elseif ($_ -is [DateTime]) {
                                        [DateTime]::Parse($_).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
                                    }
                                    elseif ($_.PSObject.Methods.Name -contains 'ToString') {
                                        $_.ToString()
                                    }
                                    else {
                                        return
                                    }
                                }
                            }

                            if (
                                $null -eq $params.UseQuotes -or
                                $params.UseQuotes -eq 'Always' -or
                                (
                                    $params.UseQuotes -eq 'AsNeeded' -and
                                    (
                                        $key -like "*$($params.Delimiter)*" -or
                                        $key -like '*"*' -or
                                        $key -match "`n|`r"
                                    )
                                )
                            ) {
                                $key = "`"$($key -replace '`"', '`"`"')`""
                            }

                            if (
                                -not [string]::IsNullOrEmpty($val) -and
                                (
                                    $null -eq $params.UseQuotes -or
                                    $params.UseQuotes -eq 'Always' -or
                                    (
                                        $params.UseQuotes -eq 'AsNeeded' -and
                                        (
                                            $val -like "*$($params.Delimiter)*" -or
                                            $val -like '*"*' -or
                                            $val -match "`n|`r"
                                        )
                                    )
                                )
                            ) {
                                $val = "`"$($val -replace '`"', '`"`"')`""
                            }

                            "$key$($params.Delimiter)$val$missingColumnsString"
                        }
                    }
                }
            )
        }
    }
    catch {
        throw $_
    }
    finally {
        if ($tempFile) { Remove-Item -Path $tempFile -Force -ErrorAction SilentlyContinue }
    }

    Write-Auto_FunctionEnd $MyInvocation -OnceOnly
}