all repos

viye @ fbda676c3bdbee9f984c64d5e09cbeb8555cda89

my shot at reimplementing xiki

viye/internal/plumbing/weather/weather.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
add weather tool, 1 month ago
1
package weather
2
3
import (
4
	"context"
5
	"encoding/json"
6
	"errors"
7
	"fmt"
8
	"net/http"
9
	"net/url"
10
	"strconv"
11
	"time"
12
13
	"github.com/olexsmir/viye/internal/config"
14
	"github.com/olexsmir/viye/internal/viye"
15
)
16
17
// | Berlin
18
// | Today 🌧️ 12° / ☀️ 19° / 🌤️ 15°
19
// | Sat   ☁️ 16° (avg)
20
// | Sun   🌧️ 14° (avg)
21
22
type Tool struct{}
23
24
func (Tool) Name() string               { return "weather" }
25
func (Tool) Match(c *viye.Context) bool { return c.Cmd == "weather" }
26
func (Tool) Execute(c *viye.Context) (string, error) {
27
	city, err := getCity(c)
28
	if err != nil {
29
		return "", err
30
	}
31
32
	fc, err := forecast(city)
33
	if err != nil {
34
		return "", err
35
	}
36
37
	return viye.FormatOutputList(render(city, fc)), nil
38
}
39
40
func getCity(c *viye.Context) (string, error) {
41
	var city string
42
	if len(c.Args) == 1 {
43
		city = c.Args[0]
44
	} else {
45
		cfg, err := config.Load()
46
		if err != nil {
47
			return "", err
48
		}
49
		city = cfg.Get("city")
50
	}
51
	if city == "" {
52
		return "", errors.New("please provide a city option")
53
	}
54
	return city, nil
55
}
56
57
func render(city string, fc weatherResp) []string {
58
	if len(fc.Weather) < 3 || len(fc.Weather[0].Hourly) < 8 {
59
		return []string{"no forecast data"}
60
	}
61
62
	lines := []string{city}
63
64
	t := fc.Weather[0].Hourly
65
	lines = append(lines, fmt.Sprintf("Today %s %s° / %s %s° / %s %s°",
66
		emoji(t[2].Code), t[2].TempC, // morning
67
		emoji(t[4].Code), t[4].TempC, // midday
68
		emoji(t[7].Code), t[7].TempC)) // evening
69
70
	for _, d := range fc.Weather[1:3] {
71
		mi, _ := strconv.Atoi(d.Min)
72
		ma, _ := strconv.Atoi(d.Max)
73
		lines = append(lines, fmt.Sprintf("%-5s %s %.0f° (avg)",
74
			weekday(d.Date), emoji(d.Hourly[4].Code), (float64(mi)+float64(ma))/2))
75
	}
76
	return lines
77
}
78
79
func weekday(date string) string {
80
	t, _ := time.Parse("2006-01-02", date)
81
	return t.Weekday().String()[:3]
82
}
83
84
// api
85
86
type weatherResp struct {
87
	Weather []day `json:"weather"`
88
}
89
90
// hour is one 3-hourly reading; 8 readings cover a day, hours 0..21.
91
type hour struct {
92
	TempC string `json:"tempC"`
93
	Code  string `json:"weatherCode"`
94
}
95
96
type day struct {
97
	Date   string `json:"date"`
98
	Min    string `json:"mintempC"`
99
	Max    string `json:"maxtempC"`
100
	Hourly []hour `json:"hourly"`
101
}
102
103
// forecast fetches the 3-day forecast for a city via wttr.in (Open-Meteo data).
104
func forecast(city string) (weatherResp, error) {
105
	u := "https://wttr.in/" + url.QueryEscape(city) + "?format=j1"
106
	ctx, cancel := context.WithTimeout(context.Background(), viye.Timeout)
107
	defer cancel()
108
109
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
110
	if err != nil {
111
		return weatherResp{}, err
112
	}
113
	resp, err := http.DefaultClient.Do(req)
114
	if err != nil {
115
		return weatherResp{}, err
116
	}
117
	defer resp.Body.Close()
118
	if resp.StatusCode != http.StatusOK {
119
		return weatherResp{}, fmt.Errorf("api returned %s", resp.Status)
120
	}
121
122
	var out weatherResp
123
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
124
		return weatherResp{}, err
125
	}
126
	if len(out.Weather) == 0 {
127
		return weatherResp{}, fmt.Errorf("city %q not found", city)
128
	}
129
	return out, nil
130
}
131
132
// emoji maps a wttr.in weather code to its phenomenon emoji.
133
func emoji(code string) string {
134
	c, _ := strconv.Atoi(code)
135
	switch {
136
	case c == 113:
137
		return "☀️"
138
	case c == 116:
139
		return "🌤️"
140
	case c == 119 || c == 122:
141
		return "☁️"
142
	case c == 143 || c == 248 || c == 260:
143
		return "🌫️"
144
	case c == 179 || c == 227 || c == 230 ||
145
		c >= 317 && c <= 338 || c == 350 ||
146
		c >= 362 && c <= 377 || c == 392 || c == 395: // snow, sleet
147
		return "🌨️"
148
	case c == 176 || c == 182 || c == 185 || c == 200 ||
149
		c >= 263 && c <= 314 || c >= 353 && c <= 359 ||
150
		c == 386 || c == 389: // drizzle, rain
151
		return "🌧️"
152
	default:
153
		return "🌡️"
154
	}
155
}