diff options
| author | twells46 <tom@wellsth.com> | 2026-03-07 08:50:19 -0600 |
|---|---|---|
| committer | twells46 <tom@wellsth.com> | 2026-03-07 09:07:19 -0600 |
| commit | d2b6b3060cfbeb17047f1fd1f989677f28f677eb (patch) | |
| tree | 1f17a8d76ef752bee60f20d338d56fdd4a597fb3 | |
Initial commit
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 29 | ||||
| -rw-r--r-- | go.mod | 34 | ||||
| -rw-r--r-- | go.sum | 58 | ||||
| -rw-r--r-- | main.go | 223 | ||||
| -rw-r--r-- | makefile | 2 | ||||
| -rw-r--r-- | openWeather.go | 272 |
7 files changed, 619 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f6eb93 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +porch diff --git a/README.md b/README.md new file mode 100644 index 0000000..9717c3d --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Porch + +Step onto the `porch` and see the weather! + +## Usage + +First, place your OpenWeatherMap API key in `~/.config/porch/key.txt`. +You can get one for free [here](https://openweathermap.org/). +If you wish, you can place your home city in `~/.config/porch/defaultCity.txt`. + +To get weather for your default city (if set), run: + +```sh +porch +``` + +To get weather for any city, run: + +```sh +porch <city> +``` + +For example: + +```sh +porch New York +``` + +`porch` can handle spaces in city names. @@ -0,0 +1,34 @@ +module git.wellsth.com/twells46/porch + +go 1.25.4 + +require ( + charm.land/lipgloss/v2 v2.0.0 // indirect + github.com/NimbleMarkets/ntcharts v0.4.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/bubbletea v1.2.2 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/lipgloss v1.0.0 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.20.0 // indirect +) @@ -0,0 +1,58 @@ +charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= +charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= +github.com/NimbleMarkets/ntcharts v0.4.0 h1:BtrER5o6s3xMAebhSDQZpdFdfVMGMpV4Qz8lD+Qiw5g= +github.com/NimbleMarkets/ntcharts v0.4.0/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.2.2 h1:EMz//Ky/aFS2uLcKqpCst5UOE6z5CFDGRsUpyXz0chs= +github.com/charmbracelet/bubbletea v1.2.2/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU= +github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= @@ -0,0 +1,223 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + "charm.land/lipgloss/v2" + "github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart" +) + +var block = lipgloss.NewStyle(). + Padding(1). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Magenta) +var bold = lipgloss.NewStyle(). + Bold(true) +var tStyle = bold + +// TODO Faint should render based on light/dark terminal theme +var faint = lipgloss.NewStyle(). + Foreground(lipgloss.BrightWhite) + +var temperature_colors = lipgloss.Blend1D(20, + lipgloss.Color("#94006f"), + lipgloss.Blue, + lipgloss.Blue, + lipgloss.Green, + lipgloss.Color("#ff5500"), + lipgloss.Red, +) + +// TODO Handle light theme +var rain_colors = lipgloss.Blend1D(10, + lipgloss.White, + lipgloss.Blue, +) + +func main() { + weather, coords := GetWeather() + header := fmt.Sprintf("%s, %s (%s, %s)\n", + bold.Render(coords.Name), + bold.Render(coords.State), + faint.Render(ff(weather.Lat)), + faint.Render(ff(weather.Lon)), + ) + current := fmtCurrent(weather) + minutely := fmtMinutely(weather) + hourly := fmtHourly(weather) + + lipgloss.Println(lipgloss.JoinVertical(lipgloss.Center, + header, + current, + minutely, + hourly, + )) +} + +func fmtHourly(w WeatherResponse) string { + hours := make([]string, len(w.Hourly)) + // TODO Make this not ugly and include units + for i, v := range w.Hourly { + // For now only show next 8 + // 48 available later + if i > 7 { + break + } + temperature_color := int(v.Temp / 5) + tStyle = tStyle.Foreground(temperature_colors[temperature_color]) + rain_color := int(v.Pop * 9) + rStyle := tStyle.Foreground(rain_colors[rain_color]) + now := time.Unix(v.Dt, 0).Local() + hours[i] = block.Render(lipgloss.JoinVertical(lipgloss.Center, + now.Format(time.DateOnly), + now.Format(time.Kitchen), + tStyle.Render(fmt.Sprintf("%g°F", v.Temp)), + rStyle.Render(fmt.Sprintf("雨%d%%", int(v.Pop*100))), + )) + } + + return lipgloss.JoinHorizontal(lipgloss.Center, hours...) +} + +/* +func fmtHourlyGraph(w WeatherResponse) string { + dataset := make([]timeserieslinechart.TimePoint, len(w.Hourly)) + + for i, v := range w.Hourly { + fmt.Println(linechart.DefaultLabelFormatter()(i, float64(v.Dt))) + dataset[i] = timeserieslinechart.TimePoint{ + Time: time.Unix(v.Dt, 0).Local(), + Value: v.Temp, + } + } + chart := timeserieslinechart.New( + 80, + 10, + timeserieslinechart.WithTimeSeries(dataset), + timeserieslinechart.WithXLabelFormatter(func(i int, _ float64) string { + res := time.Unix(w.Hourly[0].Dt, 0).Add(time.Hour * time.Duration(i)) + return res.Local().Format(time.Kitchen) + }), + ) + chart.DrawBraille() + + return block.Render(chart.View()) +} +*/ + +func fmtMinutely(w WeatherResponse) string { + dataset := make([]timeserieslinechart.TimePoint, len(w.Minutely)) + + empty := true + for i, v := range w.Minutely { + now := time.Unix(v.Dt, 0).Local() + if v.Precipitation != 0 { + empty = false + } + dataset[i] = timeserieslinechart.TimePoint{ + Time: now, + Value: v.Precipitation, + } + } + + if empty { + return "" + } + + tslc := timeserieslinechart.New( + 80, + 10, + timeserieslinechart.WithXLabelFormatter(func(_ int, d float64) string { + res := (int64(d) - w.Minutely[0].Dt) / 60 + return strconv.Itoa(int(res)) + }), + timeserieslinechart.WithTimeSeries(dataset), + ) + + tslc.DrawBraille() + + return block.BorderForeground(lipgloss.Blue).Render( + lipgloss.JoinVertical(lipgloss.Center, + bold.Render("Precipitation"), + lipgloss.JoinHorizontal(lipgloss.Center, + "mm/h", + tslc.View(), + ), + "minutes", + ), + ) +} + +func fmtCurrent(w WeatherResponse) string { + // TODO This color gradient only handles 0-100 + temperature_color := int(w.Current.Temp / 5) + tStyle = tStyle.Foreground(temperature_colors[temperature_color]) + + var b strings.Builder + + // Temperature + fmt.Fprintf(&b, "%s°F (%s°F)", + tStyle.Render(ff(w.Current.Temp)), + tStyle.Render(ff(w.Current.FeelsLike)), + ) + temp := block.Render(b.String()) + b.Reset() + + fmt.Fprintf(&b, "Humidity: %s%%", + bold.Render(strconv.Itoa(w.Current.Humidity)), + ) + humidity := block.Render(b.String()) + b.Reset() + + fmt.Fprintf(&b, "Clouds: %s%%", + bold.Render(strconv.Itoa(w.Current.Clouds)), + ) + clouds := block.Render(b.String()) + b.Reset() + + fmt.Fprintf(&b, "%s° at %s MPH", + bold.Render(strconv.Itoa(w.Current.WindDeg)), + bold.Render(ff(w.Current.WindSpeed)), + ) + wind := block.Render(b.String()) + b.Reset() + + var rain string + if w.Current.Rain != nil { + rain = block. + BorderForeground(lipgloss.Blue). + Render(fmt.Sprintf("Rain:\n%v mm/h", w.Current.Rain.Hour)) + b.Reset() + } + + var snow string + if w.Current.Snow != nil { + snow = block. + BorderForeground(lipgloss.Blue). + Render(fmt.Sprintf("Snow:\n%v mm/h", w.Current.Snow.Hour)) + b.Reset() + } + + now := time.Unix(w.Current.Dt, 0).Local() + subheader := fmt.Sprintf("%s with %s", now.Format(time.Kitchen), w.Current.Weather[0].Description) + + return lipgloss.JoinVertical(lipgloss.Center, + subheader, + lipgloss.JoinHorizontal(lipgloss.Top, + temp, + humidity, + clouds, + wind, + rain, + snow, + ), + ) +} + +// [f]ormat [f]loat +func ff(f float64) string { + return strconv.FormatFloat(f, 'f', -1, 64) +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..58199ed --- /dev/null +++ b/makefile @@ -0,0 +1,2 @@ +default: main.go openWeather.go + go build -ldflags="-s -w" -trimpath diff --git a/openWeather.go b/openWeather.go new file mode 100644 index 0000000..84f7cfd --- /dev/null +++ b/openWeather.go @@ -0,0 +1,272 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" +) + +type coordsResponse []struct { + Name string `json:"name"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Country string `json:"country"` + State string `json:"state"` +} + +type Coords struct { + Name string `json:"name"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Country string `json:"country"` + State string `json:"state"` +} + +type Rain struct { + Hour float64 `json:"1h"` +} +type Snow struct { + Hour float64 `json:"1h"` +} + +type WeatherResponse struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Timezone string `json:"timezone"` + TimezoneOffset int `json:"timezone_offset"` + Current struct { + Dt int64 `json:"dt"` + Sunrise int `json:"sunrise"` + Sunset int `json:"sunset"` + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Pressure int `json:"pressure"` + Humidity int `json:"humidity"` + DewPoint float64 `json:"dew_point"` + Uvi float64 `json:"uvi"` + Clouds int `json:"clouds"` + Visibility int `json:"visibility"` + WindSpeed float64 `json:"wind_speed"` + WindDeg int `json:"wind_deg"` + Rain *Rain `json:"rain,omitempty"` + Snow *Snow `json:"snow,omitempty"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + } `json:"current"` + Minutely []struct { + Dt int64 `json:"dt"` + Precipitation float64 `json:"precipitation"` + } `json:"minutely"` + Hourly []struct { + Dt int64 `json:"dt"` + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Pressure int `json:"pressure"` + Humidity int `json:"humidity"` + DewPoint float64 `json:"dew_point"` + Uvi float64 `json:"uvi"` + Clouds int `json:"clouds"` + Visibility int `json:"visibility,omitempty"` + WindSpeed float64 `json:"wind_speed"` + WindDeg int `json:"wind_deg"` + WindGust float64 `json:"wind_gust"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Pop float64 `json:"pop"` + Rain *Rain `json:"rain,omitempty"` + Snow *Snow `json:"snow,omitempty"` + } `json:"hourly"` + Daily []struct { + Dt int64 `json:"dt"` + Sunrise int `json:"sunrise"` + Sunset int `json:"sunset"` + Moonrise int `json:"moonrise"` + Moonset int `json:"moonset"` + MoonPhase float64 `json:"moon_phase"` + Summary string `json:"summary"` + Temp struct { + Day float64 `json:"day"` + Min float64 `json:"min"` + Max float64 `json:"max"` + Night float64 `json:"night"` + Eve float64 `json:"eve"` + Morn float64 `json:"morn"` + } `json:"temp"` + FeelsLike struct { + Day float64 `json:"day"` + Night float64 `json:"night"` + Eve float64 `json:"eve"` + Morn float64 `json:"morn"` + } `json:"feels_like"` + Pressure int `json:"pressure"` + Humidity int `json:"humidity"` + DewPoint float64 `json:"dew_point"` + WindSpeed float64 `json:"wind_speed"` + WindDeg int `json:"wind_deg"` + WindGust float64 `json:"wind_gust"` + Weather []struct { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Clouds int `json:"clouds"` + Pop float64 `json:"pop"` + Rain float64 `json:"rain,omitempty"` + Uvi float64 `json:"uvi"` + } `json:"daily"` +} + +func GetWeather() (WeatherResponse, Coords) { + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("Failed to read homedir with %v", err) + } + key := readFile(fmt.Sprint(home, "/.config/porch/key.txt")) + var city string + if len(os.Args) < 2 { + city = readFile(fmt.Sprint(home, "/.config/porch/defaultCity.txt")) + } else { + var b strings.Builder + b.WriteString(os.Args[1]) + if len(os.Args) > 2 { + for _, v := range os.Args[2:] { + b.WriteString(fmt.Sprintf(" %s", v)) + } + } + city = b.String() + } + + cityMemo := fmt.Sprint(home, "/.config/porch/cities.csv") + var coords Coords + coords, found := memoRead(city, cityMemo) + if !found { + cityQuery := fmtCityQuery(strings.ReplaceAll(city, " ", "_"), key) + resp := getUrlResponse(cityQuery) + var coordsResp coordsResponse + err := json.Unmarshal(resp, &coordsResp) + if err != nil { + panic(err) + } + if len(coordsResp) < 1 { + log.Fatalf("No results found for city \"%s\"\n", city) + } + coords = coordsResp[0] + rec := coords.toStringRec() + memoWrite(rec, cityMemo) + } + + weatherQuery := fmtWeatherQuery(coords.Lat, coords.Lon, key) + resp := getUrlResponse(weatherQuery) + var weatherResp WeatherResponse + err = json.Unmarshal(resp, &weatherResp) + if err != nil { + panic(err) + } + return weatherResp, coords +} + +func readFile(keyfile string) string { + k, err := os.ReadFile(keyfile) + if err != nil { + panic(err) + } + return strings.TrimSpace(string(k)) +} + +func fmtCityQuery(city string, key string) string { + return fmt.Sprintf("https://api.openweathermap.org/geo/1.0/direct?q=%s&limit=1&appid=%s", city, key) +} + +func fmtWeatherQuery(lat float64, lon float64, key string) string { + return fmt.Sprintf("https://api.openweathermap.org/data/3.0/onecall?lat=%f&lon=%f&appid=%s&units=imperial", lat, lon, key) +} + +func getUrlResponse(u string) []byte { + resp, err := http.Get(u) + if err != nil { + panic(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + return body +} + +func (c Coords) toStringRec() []string { + return []string{ + c.Name, + strconv.FormatFloat(c.Lat, 'f', -1, 64), + strconv.FormatFloat(c.Lon, 'f', -1, 64), + c.Country, + c.State, + } +} +func coordsFromStringRec(rec []string) Coords { + lat, err := strconv.ParseFloat(rec[1], 64) + if err != nil { + panic(err) + } + lon, err := strconv.ParseFloat(rec[2], 64) + if err != nil { + panic(err) + } + return Coords{ + rec[0], + lat, + lon, + rec[3], + rec[4], + } +} + +func memoRead(city string, memoP string) (Coords, bool) { + f, err := os.OpenFile(memoP, os.O_RDONLY|os.O_CREATE, 0666) + if err != nil { + panic(err) + } + defer f.Close() + + reader := csv.NewReader(f) + existing, err := reader.ReadAll() + if err != nil { + panic(err) + } + + // Does this record already exist? + for _, rec := range existing { + if rec[0] == city { + return coordsFromStringRec(rec), true + } + } + return Coords{}, false +} + +// Write a record. Does not check for duplication. +func memoWrite(r []string, memoP string) { + f, err := os.OpenFile(memoP, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + panic(err) + } + defer f.Close() + + writer := csv.NewWriter(f) + writer.Write(r) + writer.Flush() +} |