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 } 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"` 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) 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 }