weather/location.go

328 lines
7.3 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// free api for reverse geocoding based on IP
const revGeoURL = "http://ip-api.com/json/"
type Location struct {
Lat float64
Lon float64
City string
State string
Country string
Zipcode string
DisplayName string
RadarStation string
}
func NewLocation() *Location {
return &Location{}
}
func (l *Location) UnmarshalJSON(b []byte) error {
var aux struct {
Lat json.RawMessage `json:"lat"`
Lon json.RawMessage `json:"lon"`
DisplayName string `json:"display_name"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country"`
Zipcode string `json:"zipcode"`
RadarStation string `json:"RadarStation"`
Address struct {
City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Hamlet string `json:"hamlet"`
County string `json:"county"`
State string `json:"state"`
Country string `json:"country"`
Zipcode string `json:"postcode"`
} `json:"address"`
}
if err := json.Unmarshal(b, &aux); err != nil {
return err
}
lat, err := parseJSONFloat(aux.Lat)
if err != nil {
return err
}
lon, err := parseJSONFloat(aux.Lon)
if err != nil {
return err
}
// copy over values
l.Lat = lat
l.Lon = lon
l.City = firstNonEmpty(
aux.Address.City,
aux.Address.Town,
aux.Address.Village,
aux.Address.Hamlet,
aux.Address.County,
aux.City,
)
l.State = firstNonEmpty(aux.Address.State, aux.State)
l.Country = firstNonEmpty(aux.Address.Country, aux.Country)
l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode)
l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City)
l.RadarStation = aux.RadarStation
return nil
}
func SearchLocations(ctx context.Context, query, userAgent string) ([]Location, error) {
queryURL := fmt.Sprintf(
"https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1&limit=10",
url.QueryEscape(query),
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil)
if err != nil {
return []Location{}, err
}
req.Header.Set("User-Agent", userAgent)
client := &http.Client{
Timeout: 15 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return []Location{}, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return []Location{}, fmt.Errorf("request failed with status %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return []Location{}, err
}
var locations []Location
if err := json.Unmarshal(b, &locations); err != nil {
return []Location{}, err
}
return locations, nil
}
// func (l *Location) getCoords() error {
//
// res, err := http.Get(revGeoURL)
//
// if err != nil {
// return err
// }
//
// body, err := io.ReadAll(res.Body)
// res.Body.Close()
// if res.StatusCode > 299 {
// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode)
// return errors.New(errMsg)
// }
//
// if err != nil {
// return err
// }
//
// // unmarshall response into struct
// if err := json.Unmarshal(body, l); err != nil {
// return err
// }
//
// l.Found = true
//
// return nil
// }
//
// func (l *Location) getStation() error {
// // generate url based on coords
// url := fmt.Sprintf("https://api.weather.gov/points/%f,%f", l.Lat, l.Lon)
//
// res, err := http.Get(url)
//
// if err != nil {
// return err
// }
//
// body, err := io.ReadAll(res.Body)
// res.Body.Close()
// if res.StatusCode > 299 {
// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode)
// return errors.New(errMsg)
// }
//
// if err != nil {
// return err
// }
//
// // send another request to station URL to find closest
// stationURL := gjson.Get(string(body), "properties.observationStations")
//
// res, err = http.Get(stationURL.String())
//
// if err != nil {
// return err
// }
//
// body, err = io.ReadAll(res.Body)
// res.Body.Close()
// if res.StatusCode > 299 {
// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode)
// return errors.New(errMsg)
// }
//
// if err != nil {
// return err
// }
//
// station := gjson.Get(string(body), "features.0.properties.stationIdentifier")
//
// if station.String() == "" {
// return errors.New("Station not found")
// }
//
// l.Station = station.String()
//
// return nil
// }
// for debugging
func (l *Location) String() string {
return fmt.Sprintf("%s, %s %s (%f, %f)", l.City, l.State, l.Zipcode, l.Lat, l.Lon)
}
func (l *Location) Label() string {
city := strings.TrimSpace(l.City)
state := strings.TrimSpace(l.State)
if city != "" && state != "" {
return fmt.Sprintf("%s, %s", city, state)
}
if city != "" {
return city
}
if l.DisplayName != "" {
return l.DisplayName
}
return strings.TrimSpace(fmt.Sprintf("%s %s", city, l.State))
}
func SelectLocation(results []Location) (Location, error) {
switch len(results) {
case 0:
return Location{}, fmt.Errorf("no locations found")
case 1:
return results[0], nil
}
if !hasTTY(os.Stdin, os.Stdout) {
return Location{}, fmt.Errorf("multiple locations found; fzf requires a TTY, refine search")
}
if _, err := exec.LookPath("fzf"); err != nil {
return Location{}, fmt.Errorf("multiple locations found; install fzf or refine search")
}
lines := make([]string, 0, len(results))
for i, loc := range results {
label := loc.Label()
if loc.Country != "" && !strings.Contains(label, loc.Country) {
label = fmt.Sprintf("%s, %s", label, loc.Country)
}
lines = append(lines, fmt.Sprintf("%d\t%s (%.4f, %.4f)", i+1, label, loc.Lat, loc.Lon))
}
cmd := exec.Command("fzf", "--prompt", "Select location> ", "--height", "40%", "--reverse")
cmd.Stdin = strings.NewReader(strings.Join(lines, "\n"))
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 130 {
return Location{}, fmt.Errorf("location selection cancelled")
}
return Location{}, fmt.Errorf("fzf selection failed: %w", err)
}
selection := strings.TrimSpace(out.String())
if selection == "" {
return Location{}, fmt.Errorf("no location selected")
}
parts := strings.SplitN(selection, "\t", 2)
idx, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || idx < 1 || idx > len(results) {
return Location{}, fmt.Errorf("invalid selection")
}
return results[idx-1], nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func parseJSONFloat(raw json.RawMessage) (float64, error) {
if len(raw) == 0 {
return 0, nil
}
var asFloat float64
if err := json.Unmarshal(raw, &asFloat); err == nil {
return asFloat, nil
}
var asString string
if err := json.Unmarshal(raw, &asString); err != nil {
return 0, err
}
value, err := strconv.ParseFloat(asString, 64)
if err != nil {
return 0, err
}
return value, nil
}
func hasTTY(in, out *os.File) bool {
inStat, err := in.Stat()
if err != nil {
return false
}
outStat, err := out.Stat()
if err != nil {
return false
}
return (inStat.Mode()&os.ModeCharDevice) != 0 && (outStat.Mode()&os.ModeCharDevice) != 0
}