clihelper.weatherclient.psm1

#!/usr/bin/env pwsh
using namespace System.IO
using namespace System.Net
using namespace System.Text

#region Classes
# .SYNOPSIS
# Displays the current weather
# .EXAMPLE
# Using httpClient
# HttpUriRequest=new HttpGet(http://api.accuweather.com/locations/v1/search?q=san&apikey={your key});
# request.addHeader("Accept-Encoding", "gzip");
# // ... httpClient.execute(request);
class WeatherClient {
  static [string] $PADDING = ' ' * 2
  static [string] $HIGH_WIND_SPEEDS = 15
  static [hashtable] $weathericons = @{
    1 = "☀️"; 2 = "☀️"
    3 = "🌤"; 4 = "🌤"
    5 = "🌤"; 6 = "🌥"
    7 = "☁️"; 8 = "☁️"
    11 = "🌫"; 12 = "🌧"
    13 = "🌦"; 14 = "🌦"
    15 = "⛈"; 16 = "⛈"
    17 = "🌦"; 18 = "🌧"
    19 = "🌨"; 20 = "🌨"
    21 = "🌨"; 22 = "❄️"
    23 = "❄️"; 24 = "🌧"
    25 = "🌧"; 26 = "🌧"
    29 = "🌧"; 30 = "🌫"
    31 = "🥵"; 32 = "🥶"
  }
  static [hashtable] $weatherascii = @{
    sunny          = (
      " \\ | / ",
      " - O - ",
      " / | \\ "
    )

    partial_clouds = (
      " \\ /( ) ",
      " - O( )",
      " / ( ) "
    )

    clouds         = (
      " ( )()_ ",
      " ( ) ",
      " ( )() "
    )

    night          = (
      " . * ",
      " * . O ",
      " . . * . "
    )

    drizzle        = (
      " ' ' '",
      " ' ' ' ",
      "' ' '"
    )

    rain           = (
      " ' '' ' ' ",
      " '' ' ' ' ",
      " ' ' '' ' "
    )

    thunderstorm   = (
      " ''_/ _/' ",
      " ' / _/' '",
      " /_/'' '' "
    )

    chaos          = (
      " c__ ''' '",
      " ' '' c___",
      " c__ ' 'c_"
    )

    snow           = (
      " * '* ' * ",
      " '* ' * ' ",
      " *' * ' * "
    )

    fog            = (
      " -- _ -- ",
      " -__-- - ",
      " - _--__ "
    )

    wind           = (
      " c__ -- _ ",
      " -- _-c__ ",
      " c --___c "
    )
    unknown        = (
      " ??? ",
      " ??⛈?? ",
      " ??? "
    )
  }
  static [GeoCoordinate] GetCurrentLocation() {
    # Gets user's current location from ipinfio.io
    $locApi = "https://ipinfo.io/loc"; $location = Invoke-RestMethod -Uri $locApi -SkipHttpErrorCheck -Verbose:$false
    $lat, $lon = $location.Split(',') | ForEach-Object { [decimal]$_ }
    return [GeoCoordinate]::new($lat, $lon)
  }
  static [PSCustomObject] CallWeatherApi() {
    # Returns json from calling openweathermap.org using user's latitude and longitude
    $location = [WeatherClient]::GetCurrentLocation()
    if ($null -eq $env:OPENWEATHER_API_KEY) {
      $r = Read-Env .env -ErrorAction SilentlyContinue; if ($null -eq $r) { throw [System.IO.FileNotFoundException]::new("No .env file was found") }
      if (!$r.Name.Contains("OPENWEATHER_API_KEY")) {
        throw "No OPENWEATHER_API_KEY found in .env file"
      }; $r | Set-Env
    }
    Write-Host "Calling API for location $($location.ToString())"
    return Invoke-RestMethod -Uri "https://api.openweathermap.org/data/2.5/weather?lat=$($location.Latitude)&units=imperial&lon=$($location.Longitude)&appid=$($env:OPENWEATHER_API_KEY)" -SkipHttpErrorCheck -Verbose:$false
  }
  static [PSCustomObject] ParseWeatherData() {
    # Parses weather data from the API call
    $data = [WeatherClient]::CallWeatherApi()
    return [PSCustomObject]@{
      Name        = $data.name
      Condition   = $data.weather[0].main
      WindSpeed   = $data.wind.speed
      FeelsLike   = $data.main.feels_like
      Temperature = $data.main.temp
      ConditionId = $data.weather[0].id
    }
  }
  static [bool] IsNighttime() {
    return [weatherClient]::IsNighttime([datetime]::Now)
  }
  static [bool] IsNighttime([datetime]$date) {
    $hour = ($date).Hour; # naive approach - day-time defined as 6:00 AM -> 8:00 PM
    return !(6 -le $hour -and $hour -le 20)
  }
  static [tuple[array, string]] SelectAsciiArt([int]$conditionId, [double]$windSpeed) {
    # Split condition ID into category and subcategory
    $category = [math]::Floor($conditionId / 100)
    $subcategory = $conditionId % 100

    # Check conditions
    $windy = $windSpeed -ge [WeatherClient]::HIGH_WIND_SPEED
    $nighttime = [WeatherClient]::IsNighttime()

    # Determine appropriate ASCII art and color based on conditions
    $asciiArt, $color = switch ($true) {
      # Thunderstorm or Rain with high winds
      $(($category -in @(2, 5)) -and $windy) {
        [WeatherClient]::weatherascii['chaos'], 'DarkGray'
        break
      }
      # Clear night
      $(($category -eq 8) -and ($subcategory -eq 0) -and $nighttime) {
        [WeatherClient]::weatherascii['night'], 'Magenta'
        break
      }
      # Snow at night
      $(($category -eq 6) -and $nighttime) {
        [WeatherClient]::weatherascii['snow'], 'Blue'
        break
      }
      # Generally windy conditions
      $windy {
        [WeatherClient]::weatherascii['wind'], 'Blue'
        break
      }
      # Clear day
      $(($category -eq 8) -and ($subcategory -eq 0)) {
        [WeatherClient]::weatherascii['sunny'], 'Yellow'
        break
      }
      # Thunderstorm
      $($category -eq 2) {
        [WeatherClient]::weatherascii['thunderstorm'], 'Yellow'
        break
      }
      # Drizzle
      $($category -eq 3) {
        [WeatherClient]::weatherascii['rain'], 'Cyan'
        break
      }
      # Rain
      $($category -eq 5) {
        $asciiArt = [WeatherClient]::weatherascii['rain']
        $color = 'Blue'
        break
      }
      # Snow
      $($category -eq 6) {
        $asciiArt = [WeatherClient]::weatherascii['snow']
        $color = 'White'
        break
      }
      # Partially cloudy
      $(($category -eq 8) -and ($subcategory -in @(2, 3))) {
        $asciiArt = [WeatherClient]::weatherascii['partial_clouds']
        $color = 'Blue'
        break
      }
      # Cloudy
      $(($category -eq 8) -and ($subcategory -eq 4)) {
        $asciiArt = [WeatherClient]::weatherascii['clouds']
        $color = 'Blue'
        break
      }
      # Fog/Atmosphere
      $($category -eq 7) {
        $asciiArt = [WeatherClient]::weatherascii['fog']
        $color = 'DarkGray'
        break
      }
      default {
        [WeatherClient]::weatherascii['unknown'], 'White'
      }
    }

    return [tuple]::Create($asciiArt, $color)
  }
  static [PSCustomObject] FormatWeatherDisplay() {
    # Get weather data
    $weatherData = [WeatherClient]::ParseWeatherData()

    # Get ASCII art based on conditions
    $asciiResult = [WeatherClient]::SelectAsciiArt($weatherData.ConditionId, $weatherData.WindSpeed)
    $asciiArt = $asciiResult.Item1
    $color = $asciiResult.Item2

    # Format temperature, feels like, and wind speed
    $temperature = "{0}°F" -f [math]::Round($weatherData.Temperature)
    $feelsLike = "{0}°F" -f [math]::Round($weatherData.FeelsLike)
    $windSpeed = "{0} mph" -f [math]::Round($weatherData.WindSpeed)
    # $icon = [WeatherClient]::emojis[[int]$target.Day.Icon]

    # Get current time
    $time = (Get-Date).ToString("hh:mm tt")

    # Create weather text lines with proper padding
    $weatherText = @(
      "{0,-13} feels like {1}" -f $temperature, $feelsLike
      "{0,-13} wind {1}" -f $weatherData.Condition, $windSpeed
      "{0,-13} thanks to OpenWeatherMap.org" -f $time
    )

    # Return formatted data
    return [PSCustomObject]@{
      WeatherText = $weatherText
      AsciiArt    = $asciiArt
      Color       = $color
      Location    = $weatherData.Name
    }
  }

  # Helper method to display the weather
  static [void] DisplayWeather() {
    $weatherDisplay = [WeatherClient]::FormatWeatherDisplay()

    Write-Host "`nWeather for $($weatherDisplay.Location)" -ForegroundColor $weatherDisplay.Color
    Write-Host "------------------------" -ForegroundColor $weatherDisplay.Color

    # Display ASCII art
    $weatherDisplay.AsciiArt | ForEach-Object {
      Write-Host $_ -ForegroundColor $weatherDisplay.Color
    }

    Write-Host ""  # Empty line for spacing

    # Display weather information
    $weatherDisplay.WeatherText | ForEach-Object {
      Write-Host $_ -ForegroundColor $weatherDisplay.Color
    }
    Write-Host ""  # Empty line for spacing
  }
}

class GeoCoordinate {
  [decimal]$Latitude = 0
  [decimal]$Longitude = 0

  GeoCoordinate() {}
  GeoCoordinate([decimal]$lat, [decimal]$lon) {
    [void][GeoCoordinate]::_init_($lat, $lon, $this)
  }
  GeoCoordinate([tuple[decimal, decimal]]$coord) {
    [void][GeoCoordinate]::_init_($coord.Item1, $coord.Item2, $this)
  }
  static [GeoCoordinate] Create([decimal]$lat, [decimal]$lon) {
    return [GeoCoordinate]::_init_($lat, $lon, [GeoCoordinate]::new())
  }
  static [GeoCoordinate] Create([tuple[decimal, decimal]]$coord) {
    return [GeoCoordinate]::Create($coord.Item1, $coord.Item2)
  }
  static hidden [GeoCoordinate] _init_([decimal]$lat, [decimal]$lon, [ref]$o) {
    if (-90 -le $lat -and $lat -le 90) {
      $o.Value.Latitude = $lat
    } else {
      throw [ArgumentException]::new("Latitude must be between -90 and 90 degrees")
    }
    if (-180 -le $lon -and $lon -le 180) {
      $o.Value.Longitude = $lon
    } else {
      throw [ArgumentException]::new("Longitude must be between -180 and 180 degrees")
    }
    return $o.Value
  }
  [double] DistanceTo([GeoCoordinate]$dest) {
    return [GeoCoordinate]::DistanceTo($this, $dest)
  }
  static [double] DistanceTo([GeoCoordinate]$start, [GeoCoordinate]$dest) {
    # Method to calculate distance between two points using the Haversine formula
    $R = 6371 # Earth's radius in kilometers

    $lat1 = $start.Latitude * [Math]::PI / 180
    $lat2 = $dest.Latitude * [Math]::PI / 180
    $lon1 = $start.Longitude * [Math]::PI / 180
    $lon2 = $dest.Longitude * [Math]::PI / 180

    $dlat = $lat2 - $lat1
    $dlon = $lon2 - $lon1

    $a = [Math]::Sin($dlat / 2) * [Math]::Sin($dlat / 2) +
    [Math]::Cos($lat1) * [Math]::Cos($lat2) *
    [Math]::Sin($dlon / 2) * [Math]::Sin($dlon / 2)

    $c = 2 * [Math]::Atan2([Math]::Sqrt($a), [Math]::Sqrt(1 - $a))
    return $R * $c
  }
  [string] ToString() {
    $latDir = if ($this.Latitude -ge 0) { "N" } else { "S" }
    $lonDir = if ($this.Longitude -ge 0) { "E" } else { "W" }
    return "{0:F6}°{1} {2:F6}°{3}" -f [Math]::Abs($this.Latitude), $latDir, [Math]::Abs($this.Longitude), $lonDir
  }
  static [GeoCoordinate] Parse([string]$coordString) {
    # Method to parse coordinates from a string
    # .EXAMPLE
    # $paris = [GeoCoordinate]::Parse("48.8566°N 2.3522°E")
    # Write-Host "Paris coordinates: $paris"
    $pattern = "^([\d.]+)°([NS])\s+([\d.]+)°([EW])$"
    if ($coordString -match $pattern) {
      $lat = [decimal]$matches[1]
      $lon = [decimal]$matches[3]

      if ($matches[2] -eq "S") { $lat = - $lat }
      if ($matches[4] -eq "W") { $lon = - $lon }

      return [GeoCoordinate]::new($lat, $lon)
    }
    throw "Invalid coordinate format. Use format like '40.7128°N 74.0060°W'"
  }
}
#endregion Classes
# Types that will be available to users when they import the module.
$typestoExport = @()
$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '

    [System.Management.Automation.ErrorRecord]::new(
      [System.InvalidOperationException]::new($Message),
      'TypeAcceleratorAlreadyExists',
      [System.Management.Automation.ErrorCategory]::InvalidOperation,
      $Type.FullName
    ) | Write-Warning
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.FullName)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  Try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } Catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.WriteErrorLine($_)
  }
}

$Param = @{
  Function = $Public.BaseName
  Cmdlet   = '*'
  Alias    = '*'
  Verbose  = $false
}
Export-ModuleMember @Param