diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9588a34 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md +# Guidance for AI coding agents (Codex/ChatGPT) working in this repository. +# Follow these instructions as higher priority than general preferences. + +## Operating principles +- Optimize for correctness, safety, and maintainability over cleverness. +- Keep changes small and reviewable. Prefer multiple small commits/PRs over one large refactor. +- Preserve existing style, architecture, and public APIs unless explicitly asked to change them. + +## Dependencies (STRICT) +- Do NOT add new external dependencies (runtime or dev) without explicit approval. + - This includes package manager installs, new requirements, new npm/pip packages, new system packages, new containers/images. +- Prefer using the standard library or existing dependencies already in the repo. +- If you believe a new dependency is truly necessary: + 1) explain why, + 2) propose at least one no-new-deps alternative, + 3) estimate blast radius and maintenance risk, + 4) STOP and ask for approval before adding it. + +## Tests and verification (REQUIRED) +- For any bug fix: add/adjust a unit test that reproduces the issue and prevents regressions. +- For any new feature: add unit tests for the “happy path” and at least 1–2 edge cases. +- Prefer unit tests over integration tests unless the change is inherently integration-level. +- Run the smallest relevant test command(s) after changes: + - First: targeted tests for the changed modules. + - Then (if time/CI expectations): full unit test suite. +- If tests cannot be run in the current environment, clearly state what you would run and why. + +## Commands and environment safety +- Before running commands that could modify the environment or take a long time (e.g., installs, migrations, DB changes), + explain what you intend to run and ask for approval. +- Avoid destructive operations (deleting files, dropping DBs, resetting environments) unless explicitly requested. +- Never print, log, or exfiltrate secrets. If you detect a likely secret, redact it and point it out. + +## Implementation approach +- Start by understanding existing patterns: + - find similar code paths, + - follow established naming and folder conventions, + - reuse existing utilities/helpers. +- When making non-trivial changes: + 1) propose a short plan (3–7 bullets), + 2) list the files you expect to touch, + 3) call out risks/unknowns, + 4) then proceed. + +## Coding standards +- Prefer clear, boring code over abstractions. +- Add types/annotations where the repo uses them. +- Add docstrings/comments only where they clarify intent, invariants, or tricky logic. +- Handle errors explicitly; don’t swallow exceptions. + +## Documentation and changelog +- Update relevant docs when behavior changes (README, module docs, inline docs). +- Summarize changes at the end: + - What changed (bullets), + - Why, + - How to test (exact commands), + - Any follow-ups/todos. + +## When uncertain +- Ask a direct question or present two options with tradeoffs. +- Do not make breaking changes based on guesswork. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c00eac0 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# weather + +Small CLI for waybar-style weather output using free APIs: + +- **Location search:** OpenStreetMap Nominatim +- **Forecast:** NWS `points` → `forecastHourly` +- **Output:** single line with NerdFont icon + +## Usage + +```bash +# Set location by search (uses fzf if multiple matches) +go run . -s "Somerville, MA" +go run . --search "Somerville, MA" + +# Subsequent runs use cached location +go run . + +# Include short forecast description +go run . -d +go run . --desc + +# Enable NerdFont icons +go run . -p +go run . --pretty + +# Use Celsius +go run . -u C +go run . --units C + +# Force forecast refresh (bypass cache) +go run . -r +go run . --refresh + +# Clear cached location and forecast +go run . -c +go run . --clear-cache + +# Show all icons used by the app +go run . -i +go run . --icon-test +``` + +Note: `go run main.go` only compiles that file. Use `go run .` to include the whole package. + +Output format (default, text only): + +``` + °F in +``` + +If `-d` is set, a title-cased short forecast description is prepended. + +Output format (with `--pretty`): + +``` + °F in +``` + +## Caching + +- **Location** is cached forever until you run with `-s` again. +- **Forecast** is cached for **10 minutes** to avoid API hammering. +- Use `--clear-cache` to remove both caches. +- Use `--refresh` to bypass the forecast cache for one run. + +Cache location is stored in: + +- `$WEATHER_CACHE_DIR` if set +- Otherwise `$XDG_CACHE_HOME/weather` +- Otherwise `~/.cache/weather` + +## Notes + +- If Nominatim returns multiple matches, `fzf` is required for selection. +- If `fzf` is not installed and multiple matches are returned, the command exits with an error. +- User-Agent is automatically generated and includes hostname + repo/contact. + +## Waybar example + +```json +"custom/weather": { + "exec": "weather -p", + "interval": 60 +} +``` diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..6210fa0 --- /dev/null +++ b/cache.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + locationCacheFile = "location.json" + forecastCacheFile = "forecast.json" +) + +type Cache struct { + dir string +} + +type LocationCache struct { + Query string `json:"query"` + Location Location `json:"location"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ForecastCache struct { + Location Location `json:"location"` + Unit string `json:"unit"` + Period ForecastPeriod `json:"period"` + FetchedAt time.Time `json:"fetched_at"` +} + +func NewCache(dir string) *Cache { + return &Cache{dir: dir} +} + +func (c *Cache) SaveLocation(query string, loc Location) error { + if err := c.ensureDir(); err != nil { + return err + } + payload := LocationCache{ + Query: query, + Location: loc, + UpdatedAt: time.Now().UTC(), + } + return writeJSON(filepath.Join(c.dir, locationCacheFile), payload) +} + +func (c *Cache) LoadLocation() (Location, bool, error) { + path := filepath.Join(c.dir, locationCacheFile) + var payload LocationCache + if err := readJSON(path, &payload); err != nil { + if errors.Is(err, os.ErrNotExist) { + return Location{}, false, nil + } + return Location{}, false, err + } + return payload.Location, true, nil +} + +func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) error { + if err := c.ensureDir(); err != nil { + return err + } + payload := ForecastCache{ + Location: loc, + Unit: unit, + Period: period, + FetchedAt: time.Now().UTC(), + } + return writeJSON(filepath.Join(c.dir, forecastCacheFile), payload) +} + +func (c *Cache) LoadForecast(loc Location, unit string, ttl time.Duration) (ForecastPeriod, bool, error) { + path := filepath.Join(c.dir, forecastCacheFile) + var payload ForecastCache + if err := readJSON(path, &payload); err != nil { + if errors.Is(err, os.ErrNotExist) { + return ForecastPeriod{}, false, nil + } + return ForecastPeriod{}, false, err + } + + if payload.Unit != unit { + return ForecastPeriod{}, false, nil + } + if !sameLocation(payload.Location, loc) { + return ForecastPeriod{}, false, nil + } + if time.Since(payload.FetchedAt) > ttl { + return ForecastPeriod{}, false, nil + } + + return payload.Period, true, nil +} + +func (c *Cache) ensureDir() error { + if c.dir == "" { + return fmt.Errorf("cache directory is empty") + } + return os.MkdirAll(c.dir, 0o755) +} + +func (c *Cache) Clear() error { + if c.dir == "" { + return fmt.Errorf("cache directory is empty") + } + if err := os.Remove(filepath.Join(c.dir, locationCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.Remove(filepath.Join(c.dir, forecastCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func readJSON(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +func writeJSON(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func sameLocation(a, b Location) bool { + return a.Lat == b.Lat && a.Lon == b.Lon +} + +func cacheRoot() (string, error) { + if override := os.Getenv("WEATHER_CACHE_DIR"); override != "" { + return override, nil + } + if base := os.Getenv("XDG_CACHE_HOME"); base != "" { + return filepath.Join(base, "weather"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cache", "weather"), nil +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..bc4041c --- /dev/null +++ b/cache_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCacheLocationRoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("WEATHER_CACHE_DIR", tmp) + + cache := NewCache(tmp) + loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"} + if err := cache.SaveLocation("Testville", loc); err != nil { + t.Fatalf("save location failed: %v", err) + } + loaded, ok, err := cache.LoadLocation() + if err != nil { + t.Fatalf("load location failed: %v", err) + } + if !ok { + t.Fatalf("expected cached location") + } + if loaded.City != loc.City || loaded.Lat != loc.Lat || loaded.Lon != loc.Lon { + t.Fatalf("loaded location mismatch: %+v", loaded) + } +} + +func TestCacheForecastExpiry(t *testing.T) { + tmp := t.TempDir() + cache := NewCache(tmp) + loc := Location{Lat: 1.23, Lon: 4.56} + period := ForecastPeriod{Temp: 70, Unit: "F"} + + if err := cache.SaveForecast(loc, "F", period); err != nil { + t.Fatalf("save forecast failed: %v", err) + } + + path := filepath.Join(tmp, forecastCacheFile) + var payload ForecastCache + if err := readJSON(path, &payload); err != nil { + t.Fatalf("read forecast cache failed: %v", err) + } + payload.FetchedAt = time.Now().Add(-2 * time.Hour) + if err := writeJSON(path, payload); err != nil { + t.Fatalf("write forecast cache failed: %v", err) + } + + _, ok, err := cache.LoadForecast(loc, "F", 10*time.Minute) + if err != nil { + t.Fatalf("load forecast failed: %v", err) + } + if ok { + t.Fatalf("expected expired forecast to be ignored") + } +} + +func TestCacheClear(t *testing.T) { + tmp := t.TempDir() + cache := NewCache(tmp) + loc := Location{Lat: 1.23, Lon: 4.56} + period := ForecastPeriod{Temp: 70, Unit: "F"} + + if err := cache.SaveLocation("Testville", loc); err != nil { + t.Fatalf("save location failed: %v", err) + } + if err := cache.SaveForecast(loc, "F", period); err != nil { + t.Fatalf("save forecast failed: %v", err) + } + + if err := cache.Clear(); err != nil { + t.Fatalf("clear cache failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmp, locationCacheFile)); !os.IsNotExist(err) { + t.Fatalf("expected location cache removed, got %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, forecastCacheFile)); !os.IsNotExist(err) { + t.Fatalf("expected forecast cache removed, got %v", err) + } +} diff --git a/forecast.go b/forecast.go new file mode 100644 index 0000000..d2717c7 --- /dev/null +++ b/forecast.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type ForecastPeriod struct { + StartTime time.Time `json:"start_time"` + IsDaytime bool `json:"is_daytime"` + Temp int `json:"temperature"` + Unit string `json:"unit"` + Summary string `json:"summary"` + Pop *int `json:"pop,omitempty"` +} + +type NWSClient struct { + client *http.Client + userAgent string +} + +func NewNWSClient(userAgent string) *NWSClient { + return &NWSClient{ + client: &http.Client{Timeout: 15 * time.Second}, + userAgent: userAgent, + } +} + +func (c *NWSClient) FetchForecast(ctx context.Context, loc Location, unit string) (ForecastPeriod, error) { + pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon) + var pointsResp struct { + Properties struct { + ForecastHourly string `json:"forecastHourly"` + } `json:"properties"` + } + + if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil { + return ForecastPeriod{}, err + } + if pointsResp.Properties.ForecastHourly == "" { + return ForecastPeriod{}, fmt.Errorf("missing forecast hourly URL from NWS") + } + + var forecastResp struct { + Properties struct { + Periods []struct { + StartTime time.Time `json:"startTime"` + IsDaytime bool `json:"isDaytime"` + Temp int `json:"temperature"` + Unit string `json:"temperatureUnit"` + Summary string `json:"shortForecast"` + Pop struct { + Value *int `json:"value"` + } `json:"probabilityOfPrecipitation"` + } `json:"periods"` + } `json:"properties"` + } + + if err := c.getJSON(ctx, pointsResp.Properties.ForecastHourly, &forecastResp); err != nil { + return ForecastPeriod{}, err + } + if len(forecastResp.Properties.Periods) == 0 { + return ForecastPeriod{}, fmt.Errorf("no forecast periods returned by NWS") + } + + period := forecastResp.Properties.Periods[0] + finalUnit := period.Unit + finalTemp := period.Temp + if unit == "C" && strings.EqualFold(period.Unit, "F") { + finalTemp = int((float64(period.Temp) - 32) * 5.0 / 9.0) + finalUnit = "C" + } + + return ForecastPeriod{ + StartTime: period.StartTime, + IsDaytime: period.IsDaytime, + Temp: finalTemp, + Unit: finalUnit, + Summary: period.Summary, + Pop: period.Pop.Value, + }, nil +} + +func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "application/geo+json") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return fmt.Errorf("request failed with status %d", resp.StatusCode) + } + + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(target); err != nil { + return err + } + return nil +} + +func FormatOutput(loc Location, period ForecastPeriod, includeDesc, pretty bool) string { + conditionText := titleCase(period.Summary) + temp := formatTemperature(period, pretty) + popText, popIcon := formatPop(period, pretty) + + if pretty { + conditionIcon := iconForForecast(period.Summary, period.IsDaytime) + condition := conditionIcon + if includeDesc { + condition = fmt.Sprintf("%s %s", conditionIcon, conditionText) + } + + parts := []string{ + condition, + temp, + popIcon + popText, + "in", + loc.Label(), + } + return joinParts(parts...) + } + + condition := conditionText + if includeDesc { + condition = fmt.Sprintf("%s", conditionText) + } + + parts := []string{ + condition, + temp, + popText, + "in", + loc.Label(), + } + return joinParts(parts...) +} + +func titleCase(input string) string { + parts := strings.Fields(strings.ToLower(input)) + for i, part := range parts { + if part == "and" || part == "or" || part == "the" || part == "a" || part == "an" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + return strings.Join(parts, " ") +} + +func iconForForecast(summary string, isDay bool) string { + text := strings.ToLower(summary) + + if strings.Contains(text, "thunder") { + return "\ue31d" + } + if isPrecip(text) { + if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") { + return "\ue31a" + } + return "\ue318" + } + if isMostlySunny(text) { + if isDay { + return "\ue30c" + } + return "\ue379" + } + if isPartlySunny(text) { + if isDay { + return "\ue30c" + } + return "\ue379" + } + if isClear(text) { + if isDay { + return "\ue30d" + } + return "\ue32b" + } + if isCloudy(text) { + return "\ue312" + } + if isDay { + return "\ue30d" + } + return "\ue32b" +} + +func formatPop(period ForecastPeriod, pretty bool) (string, string) { + popValue := 0 + if period.Pop != nil { + popValue = *period.Pop + } + + if pretty { + icon := precipitationIcon(period.Summary, period.IsDaytime) + return fmt.Sprintf(" %d%%", popValue), icon + } + + return fmt.Sprintf("%d%%", popValue), "" +} + +func precipitationIcon(summary string, isDay bool) string { + text := strings.ToLower(summary) + if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") { + return "\uf2dc" + } + if strings.Contains(text, "rain") || strings.Contains(text, "shower") || strings.Contains(text, "drizzle") { + return "\ue371" + } + return "\ue371" +} + +func isPrecip(text string) bool { + return strings.Contains(text, "rain") || + strings.Contains(text, "shower") || + strings.Contains(text, "drizzle") || + strings.Contains(text, "snow") || + strings.Contains(text, "sleet") || + strings.Contains(text, "flurr") || + strings.Contains(text, "ice") || + strings.Contains(text, "hail") +} + +func isClear(text string) bool { + return strings.Contains(text, "clear") || strings.Contains(text, "sunny") +} + +func isMostlySunny(text string) bool { + return strings.Contains(text, "mostly sunny") || strings.Contains(text, "mostly clear") +} + +func isPartlySunny(text string) bool { + return strings.Contains(text, "partly sunny") || strings.Contains(text, "partly cloudy") +} + +func isCloudy(text string) bool { + return strings.Contains(text, "cloudy") || strings.Contains(text, "overcast") +} + +func formatTemperature(period ForecastPeriod, pretty bool) string { + unit := strings.ToUpper(strings.TrimSpace(period.Unit)) + if unit == "" { + unit = "F" + } + + return fmt.Sprintf("%d°%s", period.Temp, unit) +} + +func joinParts(parts ...string) string { + trimmed := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + trimmed = append(trimmed, part) + } + return strings.Join(trimmed, " ") +} diff --git a/forecast_test.go b/forecast_test.go new file mode 100644 index 0000000..9a903ae --- /dev/null +++ b/forecast_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "strings" + "testing" +) + +func TestIconForForecast(t *testing.T) { + icon := iconForForecast("Thunderstorms", true) + if icon != "\ue31d" { + t.Fatalf("expected thunderstorm icon, got %q", icon) + } + + icon = iconForForecast("Partly Cloudy", false) + if icon != "\ue379" { + t.Fatalf("expected partly cloudy night icon, got %q", icon) + } + + icon = iconForForecast("Mostly Sunny", true) + if icon != "\ue30c" { + t.Fatalf("expected mostly sunny icon, got %q", icon) + } + + icon = iconForForecast("Overcast", true) + if icon != "\ue312" { + t.Fatalf("expected cloudy icon, got %q", icon) + } + + icon = iconForForecast("Rain", true) + if icon != "\ue318" { + t.Fatalf("expected rain icon, got %q", icon) + } + + icon = iconForForecast("Snow", true) + if icon != "\ue31a" { + t.Fatalf("expected snow icon, got %q", icon) + } +} + +func TestFormatOutputPopAlways(t *testing.T) { + pop := 20 + loc := Location{City: "Testville", State: "TS"} + period := ForecastPeriod{ + Temp: 70, + Unit: "F", + Summary: "Clear", + Pop: &pop, + } + + out := FormatOutput(loc, period, false, false) + if !strings.Contains(out, "20%") { + t.Fatalf("expected pop to be shown above threshold, got %q", out) + } + if strings.Contains(out, "\ue371") || strings.Contains(out, "\uf2dc") { + t.Fatalf("expected no pop icon in plain mode, got %q", out) + } +} + +func TestFormatOutputPopIcon(t *testing.T) { + pop := 60 + loc := Location{City: "Testville", State: "TS"} + period := ForecastPeriod{ + Temp: 30, + Unit: "F", + Summary: "Snow", + Pop: &pop, + IsDaytime: true, + } + + out := FormatOutput(loc, period, false, true) + if !strings.Contains(out, "\uf2dc 60%") { + t.Fatalf("expected snow pop icon before pop, got %q", out) + } + if !strings.Contains(out, "\ue31a 30°F") { + t.Fatalf("expected precip icon before temperature, got %q", out) + } + if !strings.Contains(out, "\uf2dc 60% in") { + t.Fatalf("expected pop to precede location, got %q", out) + } +} diff --git a/icons.go b/icons.go new file mode 100644 index 0000000..c50fc3c --- /dev/null +++ b/icons.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "io" + "os" +) + +func printIconTest() { + printIconTestTo(os.Stdout) +} + +func printIconTestTo(w io.Writer) { + entries := []struct { + Label string + Icon string + }{ + {Label: "Clear/Sunny (Day)", Icon: "\ue30d"}, + {Label: "Clear/Sunny (Night)", Icon: "\ue32b"}, + {Label: "Partly/Mostly Sunny (Day)", Icon: "\ue30c"}, + {Label: "Partly/Mostly Sunny (Night)", Icon: "\ue379"}, + {Label: "Cloudy/Overcast", Icon: "\ue312"}, + {Label: "Rain", Icon: "\ue318"}, + {Label: "Snow", Icon: "\ue31a"}, + {Label: "Thunderstorm", Icon: "\ue31d"}, + {Label: "Rain Percent Chance", Icon: "\ue371"}, + {Label: "Snow Percent Chance", Icon: "\uf2dc"}, + } + + for _, entry := range entries { + fmt.Fprintf(w, "%s\t%s\n", entry.Icon, entry.Label) + } +} diff --git a/icons_test.go b/icons_test.go new file mode 100644 index 0000000..d0225dc --- /dev/null +++ b/icons_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintIconTest(t *testing.T) { + var buf bytes.Buffer + printIconTestTo(&buf) + out := buf.String() + if !strings.Contains(out, "Clear/Sunny") { + t.Fatalf("expected clear/sunny entry in icon test output") + } + if !strings.Contains(out, "Rain Percent Chance") { + t.Fatalf("expected rain percent chance entry in icon test output") + } + if !strings.Contains(out, "Snow Percent Chance") { + t.Fatalf("expected snow percent chance entry in icon test output") + } +} diff --git a/location.go b/location.go index b895d44..81e119d 100644 --- a/location.go +++ b/location.go @@ -1,10 +1,17 @@ package main import ( + "bytes" + "context" "encoding/json" "fmt" "io" "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" "time" ) @@ -12,14 +19,13 @@ import ( const revGeoURL = "http://ip-api.com/json/" type Location struct { - Lat float64 - Lon float64 - City string - State string - Country string - Zipcode string - // NWS specific location data - // Station string + Lat float64 + Lon float64 + City string + State string + Country string + Zipcode string + DisplayName string } func NewLocation() *Location { @@ -29,10 +35,19 @@ func NewLocation() *Location { func (l *Location) UnmarshalJSON(b []byte) error { var aux struct { - Lat float64 `json:"lat,string"` - Lon float64 `json:"lon,string"` - Address 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"` @@ -43,29 +58,46 @@ func (l *Location) UnmarshalJSON(b []byte) error { 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 = aux.Lat - l.Lon = aux.Lon - l.City = aux.Address.City - l.State = aux.Address.State - l.Country = aux.Address.Country - l.Zipcode = aux.Address.Zipcode + 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(query string) ([]Location, error) { - // url to perform location queries against - //locations := []Location{} - queryURL := fmt.Sprintf("https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1", query) +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.NewRequest(http.MethodGet, queryURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil) if err != nil { return []Location{}, err } - req.Header.Set("User-Agent", - "weather/0.1 (https://git.keegandeppe.com/kdeppe/weather; contact=19keegandeppe@gmail.com)") + req.Header.Set("User-Agent", userAgent) client := &http.Client{ Timeout: 15 * time.Second, @@ -78,6 +110,10 @@ func SearchLocations(query string) ([]Location, error) { } 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 @@ -86,14 +122,9 @@ func SearchLocations(query string) ([]Location, error) { var locations []Location if err := json.Unmarshal(b, &locations); err != nil { - panic(err) + return []Location{}, err } - // l := NewLocation() - // l.UnmarshalJSON(b) - - fmt.Printf("%+v\n", locations) - return locations, nil } @@ -183,3 +214,111 @@ 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 +} diff --git a/location_test.go b/location_test.go new file mode 100644 index 0000000..9caf6ae --- /dev/null +++ b/location_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +func TestLocationUnmarshalCityFallback(t *testing.T) { + payload := []byte(`{ + "lat": "42.1234", + "lon": "-71.5678", + "display_name": "Somerville, Massachusetts, USA", + "address": { + "town": "Somerville", + "state": "Massachusetts", + "country": "United States", + "postcode": "02143" + } + }`) + + var loc Location + if err := json.Unmarshal(payload, &loc); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if loc.City != "Somerville" { + t.Fatalf("expected city fallback to town, got %q", loc.City) + } + if loc.State != "Massachusetts" { + t.Fatalf("expected state, got %q", loc.State) + } +} + +func TestLocationLabel(t *testing.T) { + loc := Location{City: "Somerville", State: "MA"} + if got := loc.Label(); got != "Somerville, MA" { + t.Fatalf("unexpected label: %q", got) + } + + loc = Location{DisplayName: "Somerville, Massachusetts, USA"} + if got := loc.Label(); got != loc.DisplayName { + t.Fatalf("unexpected label: %q", got) + } +} + +func TestHasTTYFalse(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "tty-check-*") + if err != nil { + t.Fatalf("temp file: %v", err) + } + defer f.Close() + + if hasTTY(f, f) { + t.Fatalf("expected non-tty for temp file") + } +} diff --git a/main.go b/main.go index bc5b240..5aac31b 100644 --- a/main.go +++ b/main.go @@ -1,89 +1,139 @@ package main import ( + "context" + "flag" "fmt" + "os" + "strings" "time" ) +const ( + defaultForecastTTL = 10 * time.Minute +) + func main() { - - SearchLocations("Somerville") - - return - w := NewWeather() - - // if err := w.getLatest(); err != nil { - // panic(err) - // } - - fmt.Println(w) -} - -type HourForecast struct { - StartTime time.Time - EndTime time.Time - Temperature float64 - TemperatureUnit string - ProbabilityOfPercipitation float64 - WindSpeed float64 - WindDirection string - Icon string -} - -type Weather struct { - *Location - Forecast []*HourForecast - ForecastURL string -} - -func NewWeather() *Weather { - l := NewLocation() - // try to set location - // if err := l.getCoords(); err != nil { - // panic(err) - // } - // - // if err := l.getStation(); err != nil { - // panic(err) - // } - - return &Weather{ - Location: l, + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) } } -// func (w *Weather) getLatest() error { -// url := fmt.Sprintf("https://api.weather.gov/stations/%s/observations/latest", w.Location.Station) -// -// 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 -// } -// -// temp := gjson.Get(string(body), "properties.temperature.value") -// humidity := gjson.Get(string(body), "properties.relativeHumidity.value") -// cond := gjson.Get(string(body), "properties.textDescription") -// -// // convert to Farenheit -// w.Temperature = temp.Float()*(9.0/5) + 32 -// w.Humidity = humidity.Float() -// w.Conditions = cond.String() -// -// return nil -// } -// -// func (w *Weather) String() string { -// return fmt.Sprintf("%s %.1f deg %.1f%% RH in %s, %s", w.Conditions, w.Temperature, w.Humidity, w.Location.City, w.Location.State) -// } +func run() error { + var ( + search string + includeDesc bool + prettyOutput bool + temperatureU string + clearCache bool + refresh bool + iconTest bool + ) + + flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")") + flag.StringVar(&search, "search", "", "Search location (e.g. \"Somerville, MA\")") + flag.BoolVar(&includeDesc, "d", false, "Include forecast description text") + flag.BoolVar(&includeDesc, "desc", false, "Include forecast description text") + flag.BoolVar(&prettyOutput, "pretty", false, "Enable NerdFont icons") + flag.BoolVar(&prettyOutput, "p", false, "Enable NerdFont icons") + flag.StringVar(&temperatureU, "u", "F", "Temperature unit (F or C)") + flag.StringVar(&temperatureU, "units", "F", "Temperature unit (F or C)") + flag.BoolVar(&clearCache, "clear-cache", false, "Clear cached location and forecast data") + flag.BoolVar(&clearCache, "c", false, "Clear cached location and forecast data") + flag.BoolVar(&refresh, "refresh", false, "Force refresh forecast data") + flag.BoolVar(&refresh, "r", false, "Force refresh forecast data") + flag.BoolVar(&iconTest, "icon-test", false, "Print all icons used by the program") + flag.BoolVar(&iconTest, "i", false, "Print all icons used by the program") + flag.Parse() + + ua, err := buildUserAgent() + if err != nil { + return err + } + + temperatureU = strings.ToUpper(strings.TrimSpace(temperatureU)) + if temperatureU == "" { + temperatureU = "F" + } + if temperatureU != "F" && temperatureU != "C" { + return fmt.Errorf("invalid unit: %s", temperatureU) + } + + cacheDir, err := cacheRoot() + if err != nil { + return err + } + + cache := NewCache(cacheDir) + ctx := context.Background() + + if iconTest { + printIconTest() + return nil + } + + if clearCache { + if err := cache.Clear(); err != nil { + return err + } + return nil + } + + var loc Location + if search != "" { + results, err := SearchLocations(ctx, search, ua) + if err != nil { + return err + } + loc, err = SelectLocation(results) + if err != nil { + return err + } + if err := cache.SaveLocation(search, loc); err != nil { + return err + } + } else { + var ok bool + loc, ok, err = cache.LoadLocation() + if err != nil { + return err + } + if !ok { + return fmt.Errorf("no cached location found; use -s to set a location") + } + } + + client := NewNWSClient(ua) + var period ForecastPeriod + if refresh { + var err error + period, err = client.FetchForecast(ctx, loc, temperatureU) + if err != nil { + return err + } + if err := cache.SaveForecast(loc, temperatureU, period); err != nil { + return err + } + } else { + var err error + var fromCache bool + period, fromCache, err = cache.LoadForecast(loc, temperatureU, defaultForecastTTL) + if err != nil { + return err + } + if !fromCache { + period, err = client.FetchForecast(ctx, loc, temperatureU) + if err != nil { + return err + } + if err := cache.SaveForecast(loc, temperatureU, period); err != nil { + return err + } + } + } + + line := FormatOutput(loc, period, includeDesc, prettyOutput) + fmt.Println(line) + return nil +} diff --git a/useragent.go b/useragent.go new file mode 100644 index 0000000..6486be9 --- /dev/null +++ b/useragent.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +const ( + appName = "weather" + appVersion = "0.1" + appRepo = "https://git.keegandeppe.com/kdeppe/weather" + appContact = "contact=19keegandeppe@gmail.com" +) + +func buildUserAgent() (string, error) { + host, err := os.Hostname() + if err != nil { + return "", err + } + host = strings.ReplaceAll(host, " ", "-") + return fmt.Sprintf("%s/%s (%s; %s; host=%s)", appName, appVersion, appRepo, appContact, host), nil +} diff --git a/weather b/weather index 632b316..c786634 100755 Binary files a/weather and b/weather differ