Functions/Public/Utilities.ps1

# Exported utility functions (charts, lists, conversions)

Function Convert-NectarNumToTelURI {
    <#
        .SYNOPSIS
        Converts a Nectar formatted number "+12223334444 x200" into a valid TEL uri "+12223334444;ext=200"
 
        .DESCRIPTION
        Converts a Nectar formatted number "+12223334444 x200" into a valid TEL uri "+12223334444;ext=200"
         
        .PARAMETER PhoneNumber
        The phone number to convert to a TEL URI
 
        .PARAMETER TenantName
        The name of the Nectar DXP tenant. Used in multi-tenant configurations.
         
        .EXAMPLE
        Convert-NectarNumToTelsURI "+12224243344 x3344"
        Converts the above number to a TEL URI
         
        .EXAMPLE
        Get-NectarUnallocatedNumber -LocationName Jericho | Convert-NectarNumToTelURI
        Returns the next available phone number in the Jericho location in Tel URI format
             
        .NOTES
        Version 1.1
    #>

    
    Param (
        [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName, Mandatory=$true)]
        [Alias("number")]
        [string]$PhoneNumber
    )
    
    $PhoneNumber = "tel:" + $PhoneNumber.Replace(" x", ";ext=")
    Return $PhoneNumber    
}


Function Get-LatLong {
    <#
        .SYNOPSIS
        Returns the geographical coordinates for an address.
 
        .DESCRIPTION
        Returns the geographical coordinates for an address.
         
        .PARAMETER Address
        The address of the location to return information on. Include as much detail as possible.
             
        .EXAMPLE
        Get-LatLong -Address "33 Main Street, Jonestown, NY, USA"
        Retrieves the latitude/longitude for the selected location
         
        .NOTES
        Version 1.0
    #>

    
    Param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [String]$Address
    )

    Begin {
        $GoogleGeoAPIKey = [System.Environment]::GetEnvironmentVariable('GoogleGeocode_API_Key','user')
        $GeoapifyAPIKey = [System.Environment]::GetEnvironmentVariable('Geoapify_API_Key','user')

        If ($GeoapifyAPIKey) {
            $GeoProvider = 'Geoapify'
            $GeoAPIKey = $GeoapifyAPIKey
        }

        If ($GoogleGeoAPIKey) {
            $GeoProvider = 'Google'
            $GeoAPIKey = $GoogleGeoAPIKey
        }

        If(!$GeoProvider) {
            Write-Host -ForegroundColor Red 'No valid GeoProvider found. You need to register for an API key from either Google or Geoapify and save it as persistent environment variable called GoogleGeocode_API_Key or Geoapify_API_Key on this machine.' 
            Write-Host
            Write-Host -ForegroundColor Red 'For a Google API Key (requires payment) - https://developers.google.com/maps/documentation/geocoding/get-api-key'
            Write-Host -ForegroundColor Red 'For a Geoapify API Key (free account allows 3000 lookups/day) - https://www.geoapify.com/api/geocoding-api/'
            Write-Host
            Write-Host -ForegroundColor Red 'Once obtained, save the API key as a persistent environment variable using one of the following commands:'
            Write-Host -ForegroundColor Red " [System.Environment]::SetEnvironmentVariable('GoogleGeocode_API_Key', 'YourAPIKey',[System.EnvironmentVariableTarget]::User)"
            Write-Host -ForegroundColor Red " [System.Environment]::SetEnvironmentVariable('Geoapify_API_Key', 'YourAPIKey',[System.EnvironmentVariableTarget]::User)"
            Break
        }
    }
    Process {
        Switch ($GeoProvider) {
            'Geoapify' {
                $URI = "https://api.geoapify.com/v1/geocode/search?text=$Address&apiKey=$GeoAPIKey"
                Break
            }
            'Google' {
                $URI = "https://maps.googleapis.com/maps/api/geocode/json?address=$Address&key=$GeoAPIKey"
                Break
            }
        }
        Try    {
            Write-Verbose $URI
            $JSON = Invoke-RestMethod -Method GET -Uri $URI -ErrorVariable EV
            
            Switch ($GeoProvider) {
                'Geoapify' {
                    [double]$Lat = $JSON.features.geometry.coordinates[1]
                    [double]$Lng = $JSON.features.geometry.coordinates[0]
                    Break
                }
                'Google' {
                    [double]$Lat = $JSON.results.geometry.location.lat
                    [double]$Lng = $JSON.results.geometry.location.lng
                    Break
                }
            }
            
            If ($Lat) {
                $LatLong = New-Object PSObject
                $LatLong | Add-Member -type NoteProperty -Name 'Latitude' -Value $Lat
                $LatLong | Add-Member -type NoteProperty -Name 'Longitude' -Value $Lng
            }
            Else {
                Write-Host -ForegroundColor Yellow "WARNING: Address geolocation failed for $Address. Defaulting to 0:0"
                $LatLong = New-Object PSObject
                $LatLong | Add-Member -type NoteProperty -Name 'Latitude' -Value 0
                $LatLong | Add-Member -type NoteProperty -Name 'Longitude' -Value 0
            }
        }
        Catch {
            "Something went wrong. Please try again."
            $ev.message
        }
        Return $LatLong
    }
}


Function Show-GroupAndStats {
    <#
        .SYNOPSIS
        Groups a set of data by one parameter, and shows sum, average, min, max for another numeric parameter (such as duration)
 
        .DESCRIPTION
        Groups a set of data by one parameter, and shows sum, average, min, max for another numeric parameter (such as duration)
         
        .PARAMETER InputObject
        The data to group and sum. Can be pipelined
 
        .PARAMETER GroupBy
        The parameter to group on
         
        .PARAMETER SumBy
        The parameter to calculate numerical statistics. Must be a numeric field
 
        .PARAMETER ShowGroupMembers
        The field to show the members of the field used in the current grouping
 
        .PARAMETER ShowSumByAsTimeFormat
        If the SumBy parameter is in seconds (Duration is an example), format the output as dd.hh:mm:ss instead of seconds
         
        .EXAMPLE
        Get-NectarSession | Show-GroupAndStats -GroupBy CallerLocation -SumBy Duration
        Will group all calls by caller location and show sum, avg, min, max for the Duration column
             
        .NOTES
        Version 1.0
    #>

    
    Param (
        [Parameter(ValueFromPipeline, Mandatory=$True)]
        [Alias('Input')]
        [pscustomobject[]]$InputObject,
        [Parameter(Mandatory=$True)]
        [string[]]$GroupBy,
        [Parameter(Mandatory=$True)]
        [string]$SumBy,
        [Parameter(Mandatory=$False)]
        [string]$ShowGroupMembers,        
        [switch]$ShowSumByAsTimeFormat
    )    

    # Validate the parameters exist and are the proper format
    ForEach ($Item in $GroupBy) {
        If ($NULL -eq ($InputObject | Get-Member $Item)) {
            Write-Error "$Item is not a valid parameter for the source data"
            Break
        }
    }
    
    If ($NULL -eq ($InputObject | Get-Member $SumBy)) {
        Write-Error "$SumBy is not a valid parameter for the source data"
        Break
    }
    # ElseIf (($InputObject | Get-Member $SumBy).Definition -NotMatch 'int|float|double|decimal') {
    # Write-Error "$SumBy is not a numeric field"
    # Break
    # }
    
    # First, use the standard Group-Object to do the grouping.
    [System.Collections.ArrayList]$Output = @()
    $InputObject | Group-Object $GroupBy | Sort-Object Count -Descending | ForEach-Object {
        $RowData = [pscustomobject][ordered] @{}

        Write-Verbose "Name: $($_.Name)"

        # If grouping by multiple fields, the 'name' field consists of each of the grouped items separated by commas.
        # This function splits them apart into their own column
        ForEach ($Item in $GroupBy) {
            $ItemName = ($_.Name.Split(',')[$GroupBy.IndexOf($Item)])

            If (!$ItemName) { $ItemName = 'Unknown'} # Don't allow for blanks.

            $RowData | Add-Member -MemberType NoteProperty -Name $Item -Value $ItemName.Trim()
        }

        $RowData | Add-Member -MemberType NoteProperty -Name 'Count' -Value $_.Count 

        If ($ShowSumByAsTimeFormat) {
            $RowData | Add-Member -MemberType NoteProperty -Name "SUM_$SumBy" -Value ([timespan]::FromSeconds(($_.Group | Measure-Object $SumBy -Sum).Sum))
            $RowData | Add-Member -MemberType NoteProperty -Name "AVG_$SumBy" -Value ([timespan]::FromSeconds([math]::Round(($_.Group | Measure-Object $SumBy -Average).Average)))
            $RowData | Add-Member -MemberType NoteProperty -Name "MIN_$SumBy" -Value ([timespan]::FromSeconds(($_.Group | Measure-Object $SumBy -Minimum).Minimum))
            $RowData | Add-Member -MemberType NoteProperty -Name "MAX_$SumBy" -Value ([timespan]::FromSeconds(($_.Group | Measure-Object $SumBy -Maximum).Maximum))
        }
        Else {
            $RowData | Add-Member -MemberType NoteProperty -Name "SUM_$SumBy" -Value ($_.Group | Measure-Object $SumBy -Sum).Sum
            $RowData | Add-Member -MemberType NoteProperty -Name "AVG_$SumBy" -Value ([math]::Round(($_.Group | Measure-Object $SumBy -Average).Average,2))
            $RowData | Add-Member -MemberType NoteProperty -Name "MIN_$SumBy" -Value ($_.Group | Measure-Object $SumBy -Minimum).Minimum
            $RowData | Add-Member -MemberType NoteProperty -Name "MAX_$SumBy" -Value ($_.Group | Measure-Object $SumBy -Maximum).Maximum
        }

        If ($ShowGroupMembers) {
            $GroupMemberList = $NULL
            $_.Group.$ShowGroupMembers | ForEach-Object { $GroupMemberList += ($(If($GroupMemberList){','}) + $_) }
            $RowData | Add-Member -MemberType NoteProperty -Name "$($ShowGroupMembers)_List" -Value $GroupMemberList
        }
        $Output += $RowData
    }             
    Return $Output
}


Function New-Chart {
    <#
        .SYNOPSIS
        Creates a chart PNG file based on the input data
 
        .DESCRIPTION
        Creates a chart PNG file based on the input data. ONLY WORKS IN MS WINDOWS
         
        .PARAMETER InputData
        The data source to use for the chart. Can be either a variable or a command enclosed in brackets
 
        .PARAMETER TimeObjectName
        The name of the data column to use for the time (x-axis)
 
        .PARAMETER BarNames
        The names of the bars to display separated by commas. Must match up with the names of the desired column in the input data
 
        .PARAMETER BarColours
        The colours of the bars to display separated by commas. Colours will be matched up with the BarNames by position.
 
        .PARAMETER Interval
        The interval between numbers to show on the axis. Defaults to auto.
 
        .PARAMETER ChartName
        The name to use for the chart header and the filename. Defaults to the type of chart being generated.
 
        .PARAMETER ChartType
        The chart type to display. Defaults to StackedColumn.
 
        .EXAMPLE
        $Data = Get-NectarSessionCount -TimePeriod LAST WEEK
        New-Chart -InputData $Data -BarNames Good,Average,Poor -BarColors Green,Yellow,Red
        Creates a bar chart using a variable from a previous command
         
        .EXAMPLE
        New-Chart -InputData (Get-NectarSessionCount -TimePeriod LAST WEEK) -BarNames Good,Average,Poor -BarColors Green,Yellow,Red
        Same results as previous example, but shown as full command written within the New-Chart command
         
        .NOTES
        Version 1.0
    #>

    
    param (
        [Parameter(Mandatory=$True)]
        [PSCustomObject]$InputData,
        [Parameter(Mandatory=$True)]
        [string]$TimeObjectName,
        [Parameter(Mandatory=$True)]
        [string[]]$BarNames,
        [Parameter(Mandatory=$False)]
        [string[]]$BarColours,
        [Parameter(Mandatory=$False)]
        [int32]$Interval,
        [Parameter(Mandatory=$False)]
        [string]$ChartName,
        [Parameter(Mandatory=$False)]
        [ValidateSet('Area', 'Bar', 'BoxPlot', 'Bubble', 'Column', 'Doughnut', 'Line', 'Pie', 'Point', 'Polar', 'Radar', 'Range', 'RangeBar', 'RangeColumn', 'Spline', 'SplineArea', 'SplineRange', 'StackedArea', 'StackedArea100', 'StackedBar', 'StackedBar100', 'StackedColumn', 'StackedColumn100', 'StepLine',  IgnoreCase=$True)]
        [string]$ChartType = 'StackedColumn'
    )
    [void][Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms.DataVisualization")

    # Creating chart object
    # The System.Windows.Forms.DataVisualization.Charting namespace contains methods and properties for the Chart Windows forms control.
    $ChartObject            = New-Object System.Windows.Forms.DataVisualization.Charting.Chart
    $ChartObject.Width        = 2000
    $ChartObject.Height        = 1000
    $ChartObject.BackColor    = [System.Drawing.Color]::white

    # Set Chart title
    If (!$ChartName) { $ChartName = $ChartType }

    [void]$ChartObject.Titles.Add("$ChartName for $($InputData[0].TenantName)")
    $ChartObject.Titles[0].Font            = "Arial,13pt"
    $ChartObject.Titles[0].Alignment    = "TopCenter"

    # Create a chartarea to draw on and add to chart
    $ChartAreaObject                = New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
    $ChartAreaObject.Name            = "ChartArea1"
    $ChartAreaObject.AxisY.Title    = "Count"
    $ChartAreaObject.AxisX.Title    = "Date"
    $ChartAreaObject.AxisY.Interval    = $Interval
    $ChartAreaObject.AxisX.Interval    = 1
    $ChartAreaObject.BackColor        = [System.Drawing.Color]::white
    $ChartObject.ChartAreas.Add($ChartAreaObject)

    # Creating legend for the chart
    $ChartLegend        = New-Object system.Windows.Forms.DataVisualization.Charting.Legend
    $ChartLegend.name    = "Legend1"
    $ChartObject.Legends.Add($ChartLegend)

    ForEach ($Bar in $BarNames) {
        [void]$ChartObject.Series.Add($Bar)
        $ChartObject.Series[$Bar].ChartType            = $ChartType
        $ChartObject.Series[$Bar].BorderWidth        = 3
        $ChartObject.Series[$Bar].IsVisibleInLegend    = $true
        $ChartObject.Series[$Bar].chartarea            = "ChartArea1"
        $ChartObject.Series[$Bar].Legend            = "Legend1"

        If ($BarColours[$BarNames.IndexOf($Bar)]) { $ChartObject.Series[$Bar].color = $BarColours[$BarNames.IndexOf($Bar)] }

        $InputData | ForEach-Object {$NULL = $ChartObject.Series[$Bar].Points.addxy([datetime]$_.$TimeObjectName, $_.$Bar) }
    }

    # Save chart with the Time frame for identifying the usage at the specific time
    $ChartObject.SaveImage("$($ChartName.Replace(' ','_'))_$($InputData[0].TenantName).png","png")
    Write-Host "Chart saved as $($ChartName.Replace(' ','_'))_$($InputData[0].TenantName).png"
}



# From https://github.com/allynl93/getSAMLResponse-Interactive
# Unfortunately, it relies on System.Windows.Forms, which uses IE and doesn't work with most modern IDPs


Function Select-FromList {
    <#
        .SYNOPSIS
            Displays a list of items and allows the user to select one.
        .DESCRIPTION
            Displays a list of items and allows the user to select one, with optional highlighting and lowlighting of specific items.
        .PARAMETER Items
            The list of items to display.
        .PARAMETER Prompt
            The prompt to display to the user.
        .PARAMETER Highlight
            An array of strings representing the *exact* items to highlight (brighter color). Only items that match *exactly*
            (case-insensitive) will be highlighted. This can be a variable containing an array.
        .PARAMETER Lowlight
            An array of strings representing the *exact* items to lowlight (dimmer color). Only items that match *exactly*
            (case-insensitive) will be lowlighted. This can be a variable containing an array. Lowlight takes precedence over Highlight.
        .PARAMETER Default
            The item to pre-select (highlight) in the list. This takes precedence over both `Highlight` and `Lowlight`.
        .EXAMPLE
            Select-FromList -Items @("Item1", "Item2", "Item3") -Prompt "Choose an item:"
        .EXAMPLE
            Select-FromList -Items $PSList -Prompt "Choose an item:" -Default "Item2"
        .EXAMPLE
            Select-FromList -Items @("apple", "banana", "orange", "grapefruit") -Prompt "Select a fruit:" -Highlight @("orange", "grapefruit")
            # This will highlight "orange" and "grapefruit".
        .EXAMPLE
            $items = 1..10 | ForEach-Object {"Item $_"}
            Select-FromList -Items $items -Prompt "Select a number:" -Highlight @("Item 1", "Item 5", "Item 10") -Default "Item 3"
            # Highlights "Item 1", "Item 10", "Item 5". "Item 3" will be selected by default.
        .EXAMPLE
            Select-FromList -Items @("File1.txt", "File2.log", "File3.txt", "Backup.zip") -Prompt "Select a file:" -Highlight @("File1.txt", "File3.txt")
            # Highlights "File1.txt" and "File3.txt".
        .EXAMPLE
            $myHighlights = @("Item2", "Item4")
            Select-FromList -Items @("Item1", "Item2", "Item3", "Item4", "Item5") -Prompt "Select an item:" -Highlight $myHighlights
            # Highlights "Item2" and "Item4"
        .EXAMPLE
            $myHighlights = @("item2", "ITEM4") # mixed case
            $myLowlights = @("Item3")
            Select-FromList -Items @("Item1", "Item2", "Item3", "Item4", "Item5") -Prompt "Select:" -Highlight $myHighlights -Lowlight $myLowlights -Default "item3"
            # Highlights "Item2" and "Item4", Lowlights "Item3", and selects "Item3" by default.
        .EXAMPLE
            $myLowlights = @("Item3", "Item1")
            Select-FromList -Items (1..5 | ForEach-Object {"Item$_"}) -Lowlight $myLowlights
    #>

        
    Param(
        [Parameter(Mandatory = $true)]
        [string[]]$Items,
        [string]$Prompt = "Select an item:",
        [string[]]$Highlight,
        [string[]]$Lowlight,
        [string]$Default
    )
    
    # Validate if $items is empty
    If ($Items.Length -eq 0) {
        Write-Host "Error: List is empty" -ForegroundColor Red
        Return $null;
    }

    $selectedIndex = 0
    $itemsCount = $Items.Count

    # Set the initial index based on Default
    If ($Default) {
        For ($i = 0; $i -lt $itemsCount; $i++) {
            If ($Items[$i] -like "*$($Default)*") {  # Use -ieq for case-insensitive comparison
                $selectedIndex = $i
                Break
            }
        }
    }

    While ($true) {
        Clear-Host
        Write-Host $Prompt

        For ($i = 0; $i -lt $itemsCount; $i++) {
            $line = " " + ($i + 1) + ". " + $Items[$i]
            $isHighlighted = $false
            $isLowlighted = $false

            # Check for lowlight first (takes precedence, *except* when selected)
            if ($Lowlight) {
                if ($Items[$i] -in $Lowlight) {
                    $isLowlighted = $true
                }
            }

            # Check for highlight (only if not lowlighted)
            if ($Highlight -and !$isLowlighted) {
                if ($Items[$i] -in $Highlight) {
                    $isHighlighted = $true
                }
            }

            # Selected item is *always* green
            if ($i -eq $selectedIndex) {
                Write-Host ">>" -NoNewline -ForegroundColor Green
                Write-Host $line -ForegroundColor Green
            } elseif ($isLowlighted) {
                Write-Host " " -NoNewline
                Write-Host $line -ForegroundColor DarkGray  # Just Lowlighted
            } elseif ($isHighlighted) {
                Write-Host " " -NoNewline
                Write-Host $line -ForegroundColor Yellow  # Just Highlighted
            } else {
                Write-Host " " -NoNewline
                Write-Host $line  # Normal item
            }
        }

        $key = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")

        If ($key.VirtualKeyCode -eq 38) { # Up Arrow
            $selectedIndex = ($selectedIndex - 1 + $itemsCount) % $itemsCount
        } ElseIf ($key.VirtualKeyCode -eq 40) { # Down Arrow
            $selectedIndex = ($selectedIndex + 1) % $itemsCount
        } ElseIf ($key.VirtualKeyCode -eq 13) { # Enter
            Return $Items[$selectedIndex]
            Break
        } ElseIf ($key.VirtualKeyCode -eq 27) {  # ESCAPE
            Return $null
            Break
        }
    }
}