vibecoding v0.1

This commit is contained in:
spinach 2026-01-28 12:24:50 -05:00
parent b7c6c9e26a
commit 430a51b659
13 changed files with 1163 additions and 108 deletions

63
AGENTS.md Normal file
View File

@ -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 12 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 (37 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; dont 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.

86
README.md Normal file
View File

@ -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):
```
<CONDITION> <TEMP>°F <POP%> in <CITY, ST>
```
If `-d` is set, a title-cased short forecast description is prepended.
Output format (with `--pretty`):
```
<COND_GLYPH> <TEMP>°F <POP_GLYPH> <POP%> in <CITY, ST>
```
## 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
}
```

150
cache.go Normal file
View File

@ -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
}

83
cache_test.go Normal file
View File

@ -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)
}
}

270
forecast.go Normal file
View File

@ -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, " ")
}

80
forecast_test.go Normal file
View File

@ -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)
}
}

33
icons.go Normal file
View File

@ -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)
}
}

22
icons_test.go Normal file
View File

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

View File

@ -1,10 +1,17 @@
package main package main
import ( import (
"bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time" "time"
) )
@ -12,14 +19,13 @@ import (
const revGeoURL = "http://ip-api.com/json/" const revGeoURL = "http://ip-api.com/json/"
type Location struct { type Location struct {
Lat float64 Lat float64
Lon float64 Lon float64
City string City string
State string State string
Country string Country string
Zipcode string Zipcode string
// NWS specific location data DisplayName string
// Station string
} }
func NewLocation() *Location { func NewLocation() *Location {
@ -29,10 +35,19 @@ func NewLocation() *Location {
func (l *Location) UnmarshalJSON(b []byte) error { func (l *Location) UnmarshalJSON(b []byte) error {
var aux struct { var aux struct {
Lat float64 `json:"lat,string"` Lat json.RawMessage `json:"lat"`
Lon float64 `json:"lon,string"` Lon json.RawMessage `json:"lon"`
Address struct { 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"` City string `json:"city"`
Town string `json:"town"`
Village string `json:"village"`
Hamlet string `json:"hamlet"`
County string `json:"county"`
State string `json:"state"` State string `json:"state"`
Country string `json:"country"` Country string `json:"country"`
Zipcode string `json:"postcode"` Zipcode string `json:"postcode"`
@ -43,29 +58,46 @@ func (l *Location) UnmarshalJSON(b []byte) error {
return err 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 // copy over values
l.Lat = aux.Lat l.Lat = lat
l.Lon = aux.Lon l.Lon = lon
l.City = aux.Address.City l.City = firstNonEmpty(
l.State = aux.Address.State aux.Address.City,
l.Country = aux.Address.Country aux.Address.Town,
l.Zipcode = aux.Address.Zipcode 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 return nil
} }
func SearchLocations(query string) ([]Location, error) { func SearchLocations(ctx context.Context, query, userAgent string) ([]Location, error) {
// url to perform location queries against queryURL := fmt.Sprintf(
//locations := []Location{} "https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1&limit=10",
queryURL := fmt.Sprintf("https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1", query) url.QueryEscape(query),
)
req, err := http.NewRequest(http.MethodGet, queryURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil)
if err != nil { if err != nil {
return []Location{}, err return []Location{}, err
} }
req.Header.Set("User-Agent", req.Header.Set("User-Agent", userAgent)
"weather/0.1 (https://git.keegandeppe.com/kdeppe/weather; contact=19keegandeppe@gmail.com)")
client := &http.Client{ client := &http.Client{
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
@ -78,6 +110,10 @@ func SearchLocations(query string) ([]Location, error) {
} }
defer resp.Body.Close() 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) b, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return []Location{}, err return []Location{}, err
@ -86,14 +122,9 @@ func SearchLocations(query string) ([]Location, error) {
var locations []Location var locations []Location
if err := json.Unmarshal(b, &locations); err != nil { 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 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) 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
}

56
location_test.go Normal file
View File

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

206
main.go
View File

@ -1,89 +1,139 @@
package main package main
import ( import (
"context"
"flag"
"fmt" "fmt"
"os"
"strings"
"time" "time"
) )
const (
defaultForecastTTL = 10 * time.Minute
)
func main() { func main() {
if err := run(); err != nil {
SearchLocations("Somerville") fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
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,
} }
} }
// func (w *Weather) getLatest() error { func run() error {
// url := fmt.Sprintf("https://api.weather.gov/stations/%s/observations/latest", w.Location.Station) var (
// search string
// res, err := http.Get(url) includeDesc bool
// prettyOutput bool
// if err != nil { temperatureU string
// return err clearCache bool
// } refresh bool
// iconTest bool
// body, err := io.ReadAll(res.Body) )
// res.Body.Close()
// if res.StatusCode > 299 { flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")")
// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) flag.StringVar(&search, "search", "", "Search location (e.g. \"Somerville, MA\")")
// return errors.New(errMsg) 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")
// if err != nil { flag.BoolVar(&prettyOutput, "p", false, "Enable NerdFont icons")
// return err 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")
// temp := gjson.Get(string(body), "properties.temperature.value") flag.BoolVar(&clearCache, "c", false, "Clear cached location and forecast data")
// humidity := gjson.Get(string(body), "properties.relativeHumidity.value") flag.BoolVar(&refresh, "refresh", false, "Force refresh forecast data")
// cond := gjson.Get(string(body), "properties.textDescription") flag.BoolVar(&refresh, "r", false, "Force refresh forecast data")
// flag.BoolVar(&iconTest, "icon-test", false, "Print all icons used by the program")
// // convert to Farenheit flag.BoolVar(&iconTest, "i", false, "Print all icons used by the program")
// w.Temperature = temp.Float()*(9.0/5) + 32 flag.Parse()
// w.Humidity = humidity.Float()
// w.Conditions = cond.String() ua, err := buildUserAgent()
// if err != nil {
// return nil return err
// } }
//
// func (w *Weather) String() string { temperatureU = strings.ToUpper(strings.TrimSpace(temperatureU))
// return fmt.Sprintf("%s %.1f deg %.1f%% RH in %s, %s", w.Conditions, w.Temperature, w.Humidity, w.Location.City, w.Location.State) 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
}

23
useragent.go Normal file
View File

@ -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
}

BIN
weather

Binary file not shown.