358 lines
8.0 KiB
Go
358 lines
8.0 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
|
|
OfficeID 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"`
|
|
OfficeID string `json:"OfficeID"`
|
|
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
|
|
l.OfficeID = aux.OfficeID
|
|
|
|
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 {
|
|
return l.label(true)
|
|
}
|
|
|
|
func (l *Location) LabelFull() string {
|
|
return l.label(false)
|
|
}
|
|
|
|
func (l *Location) label(abbrevUS bool) string {
|
|
city := strings.TrimSpace(l.City)
|
|
state := strings.TrimSpace(l.State)
|
|
country := strings.TrimSpace(l.Country)
|
|
|
|
if abbrevUS && (country == "" || strings.EqualFold(country, "United States") || strings.EqualFold(country, "United States of America")) {
|
|
if abbr := usStateAbbreviation(state); abbr != "" {
|
|
state = abbr
|
|
}
|
|
}
|
|
|
|
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 usStateAbbreviation(state string) string {
|
|
state = strings.TrimSpace(state)
|
|
if state == "" {
|
|
return ""
|
|
}
|
|
if abbr, ok := usStateMap[state]; ok {
|
|
return abbr
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|