orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package skills
2
3import (
4 "context"
5 "fmt"
6 "regexp"
7 "strconv"
8 "strings"
9 "time"
10)
11
12// Frankfurter publishes the ECB reference rates, keyless and without a quota.
13// Rates are daily rather than live, which is right for "how much is 200 euros"
14// and wrong for trading, and the answer says which it is.
15const fxURL = "https://api.frankfurter.dev/v1/latest"
16
17// Convert turns one unit into another.
18//
19// The units half never leaves the process, because a conversion factor is a
20// constant and fetching a page to read one is absurd. The currency half is the
21// only part that needs the network.
22type Convert struct{}
23
24func (Convert) Card() Card {
25 return Card{
26 Name: "convert",
27 Does: "converts a quantity between units of temperature, length, weight, volume or speed, or between two currencies.",
28 Fires: []string{
29 "30 celsius to fahrenheit",
30 "how many km in 5 miles",
31 "100 usd in eur",
32 "convert 180 pounds to kg",
33 "how many cups is 500ml",
34 },
35 NotFor: []string{
36 "why is the euro weak",
37 "what currency does japan use",
38 "how do i convert a pdf to word",
39 "what is the exchange rate history",
40 "how much does a car cost in europe",
41 },
42 Keywords: []string{" to fahrenheit", " to celsius", " in eur", " in usd",
43 " to kg", " to pounds", " to km", " to miles", "convert "},
44 }
45}
46
47// unit is one measure and its size in the family's base unit.
48type unit struct {
49 family string
50 per float64
51 names []string
52}
53
54var units = []unit{
55 {"length", 0.001, []string{"mm", "millimetre", "millimeter", "millimetres", "millimeters"}},
56 {"length", 0.01, []string{"cm", "centimetre", "centimeter", "centimetres", "centimeters"}},
57 {"length", 1, []string{"m", "metre", "meter", "metres", "meters"}},
58 {"length", 1000, []string{"km", "kilometre", "kilometer", "kilometres", "kilometers"}},
59 {"length", 0.0254, []string{"in", "inch", "inches"}},
60 {"length", 0.3048, []string{"ft", "foot", "feet"}},
61 {"length", 0.9144, []string{"yd", "yard", "yards"}},
62 {"length", 1609.344, []string{"mi", "mile", "miles"}},
63
64 {"weight", 0.001, []string{"g", "gram", "grams", "gramme", "grammes"}},
65 {"weight", 1, []string{"kg", "kilo", "kilos", "kilogram", "kilograms"}},
66 {"weight", 0.0283495, []string{"oz", "ounce", "ounces"}},
67 {"weight", 0.453592, []string{"lb", "lbs", "pound", "pounds"}},
68 {"weight", 6.35029, []string{"st", "stone", "stones"}},
69 {"weight", 1000, []string{"tonne", "tonnes", "metric ton"}},
70
71 {"volume", 0.001, []string{"ml", "millilitre", "milliliter", "millilitres", "milliliters"}},
72 {"volume", 1, []string{"l", "litre", "liter", "litres", "liters"}},
73 {"volume", 0.236588, []string{"cup", "cups"}},
74 {"volume", 0.0147868, []string{"tbsp", "tablespoon", "tablespoons"}},
75 {"volume", 0.00492892, []string{"tsp", "teaspoon", "teaspoons"}},
76 {"volume", 3.78541, []string{"gal", "gallon", "gallons"}},
77 {"volume", 0.473176, []string{"pint", "pints"}},
78 {"volume", 0.0295735, []string{"fl oz", "fluid ounce", "fluid ounces"}},
79
80 {"speed", 1, []string{"kph", "km/h", "kmh", "kilometres per hour", "kilometers per hour"}},
81 {"speed", 1.609344, []string{"mph", "mi/h", "miles per hour"}},
82 {"speed", 3.6, []string{"m/s", "metres per second", "meters per second"}},
83 {"speed", 1.852, []string{"knot", "knots", "kt"}},
84}
85
86// Temperature is not a ratio, so it cannot live in the table above.
87var tempNames = map[string]string{
88 "c": "C", "celsius": "C", "centigrade": "C", "°c": "C",
89 "f": "F", "fahrenheit": "F", "°f": "F",
90 "k": "K", "kelvin": "K",
91}
92
93var currencies = map[string]bool{
94 "USD": true, "EUR": true, "GBP": true, "JPY": true, "CHF": true, "CAD": true,
95 "AUD": true, "NZD": true, "SEK": true, "NOK": true, "DKK": true, "PLN": true,
96 "CZK": true, "HUF": true, "RON": true, "BGN": true, "TRY": true, "ILS": true,
97 "ZAR": true, "MXN": true, "BRL": true, "INR": true, "CNY": true, "HKD": true,
98 "SGD": true, "KRW": true, "THB": true, "MYR": true, "PHP": true, "IDR": true,
99 "ISK": true,
100}
101
102var currencyWords = map[string]string{
103 "dollar": "USD", "dollars": "USD", "usd": "USD", "$": "USD",
104 "euro": "EUR", "euros": "EUR", "eur": "EUR", "€": "EUR",
105 "pound": "GBP", "pounds": "GBP", "gbp": "GBP", "£": "GBP", "sterling": "GBP",
106 "yen": "JPY", "jpy": "JPY", "¥": "JPY",
107 "franc": "CHF", "francs": "CHF", "chf": "CHF",
108 "rupee": "INR", "rupees": "INR", "inr": "INR",
109 "yuan": "CNY", "cny": "CNY", "rmb": "CNY",
110 "won": "KRW", "krw": "KRW",
111 "peso": "MXN", "pesos": "MXN", "mxn": "MXN",
112 "real": "BRL", "reais": "BRL", "brl": "BRL",
113 "rand": "ZAR", "zar": "ZAR",
114 "cad": "CAD", "aud": "AUD", "nzd": "NZD", "sek": "SEK", "nok": "NOK",
115}
116
117// The shapes a conversion is written in: "30 c to f", "how many km in 5 miles",
118// "convert 180 lb to kg". The number can sit on either side of the unit pair.
119var (
120 reAtoB = regexp.MustCompile(`(-?[\d.,]+)\s*([a-z°$€£¥/ ]{1,22}?)\s+(?:to|in|into|as)\s+([a-z°$€£¥/ ]{1,22})`)
121 reHowMany = regexp.MustCompile(`how many\s+([a-z°/ ]{1,22}?)\s+(?:are\s+)?(?:in|is)\s+(-?[\d.,]+)\s*([a-z°/ ]{1,22})`)
122)
123
124type conversion struct {
125 amount float64
126 from, to string
127}
128
129func parseConversion(q string) (conversion, bool) {
130 l := strings.ToLower(strings.TrimSpace(q))
131 l = strings.TrimSuffix(l, "?")
132 l = strings.ReplaceAll(l, "degrees ", "")
133
134 if m := reHowMany.FindStringSubmatch(l); m != nil {
135 n, err := strconv.ParseFloat(strings.ReplaceAll(m[2], ",", ""), 64)
136 if err == nil {
137 return conversion{n, strings.TrimSpace(m[3]), strings.TrimSpace(m[1])}, true
138 }
139 }
140 if m := reAtoB.FindStringSubmatch(l); m != nil {
141 n, err := strconv.ParseFloat(strings.ReplaceAll(m[1], ",", ""), 64)
142 if err == nil {
143 return conversion{n, strings.TrimSpace(m[2]), strings.TrimSpace(m[3])}, true
144 }
145 }
146 return conversion{}, false
147}
148
149func findUnit(name string) (unit, bool) {
150 name = strings.TrimSpace(name)
151 for _, u := range units {
152 for _, n := range u.names {
153 if n == name {
154 return u, true
155 }
156 }
157 }
158 return unit{}, false
159}
160
161func findCurrency(name string) (string, bool) {
162 name = strings.TrimSpace(name)
163 if c, ok := currencyWords[name]; ok {
164 return c, true
165 }
166 up := strings.ToUpper(name)
167 if currencies[up] {
168 return up, true
169 }
170 return "", false
171}
172
173func (Convert) Run(ctx context.Context, question string, d Deps) (*Result, error) {
174 start := d.now()
175 c, ok := parseConversion(question)
176 if !ok {
177 return nil, nil
178 }
179
180 if text, ok := convertTemperature(c); ok {
181 return &Result{Skill: "convert", Shape: "factual", Text: text,
182 Elapsed: d.now().Sub(start).Round(time.Millisecond).String()}, nil
183 }
184 if text, ok := convertUnits(c); ok {
185 return &Result{Skill: "convert", Shape: "factual", Text: text,
186 Elapsed: d.now().Sub(start).Round(time.Millisecond).String()}, nil
187 }
188
189 from, okF := findCurrency(c.from)
190 to, okT := findCurrency(c.to)
191 if !okF || !okT || from == to {
192 return nil, nil
193 }
194 var p struct {
195 Date string `json:"date"`
196 Rates map[string]float64 `json:"rates"`
197 }
198 u := fmt.Sprintf("%s?base=%s&symbols=%s", fxURL, from, to)
199 if err := getJSON(ctx, d, u, &p); err != nil {
200 return nil, err
201 }
202 rate, ok := p.Rates[to]
203 if !ok || rate == 0 {
204 return nil, nil
205 }
206 text := fmt.Sprintf("**%s %s**\n\n- **%s %s** at **%.4f** %s per %s\n\n"+
207 "European Central Bank reference rate for %s, which is set once a day rather than live.",
208 formatNumber(round2(c.amount*rate)), to,
209 formatNumber(round2(c.amount)), from, rate, to, from, p.Date)
210
211 return &Result{
212 Skill: "convert", Shape: "factual", Text: text,
213 Sources: []Source{{URL: "https://frankfurter.dev/", Title: "Frankfurter, ECB rates", Site: "frankfurter.dev"}},
214 Elapsed: d.now().Sub(start).Round(10 * time.Millisecond).String(),
215 }, nil
216}
217
218func convertTemperature(c conversion) (string, bool) {
219 from, okF := tempNames[strings.TrimSpace(c.from)]
220 to, okT := tempNames[strings.TrimSpace(c.to)]
221 if !okF || !okT || from == to {
222 return "", false
223 }
224 var celsius float64
225 switch from {
226 case "C":
227 celsius = c.amount
228 case "F":
229 celsius = (c.amount - 32) * 5 / 9
230 case "K":
231 celsius = c.amount - 273.15
232 }
233 var out float64
234 switch to {
235 case "C":
236 out = celsius
237 case "F":
238 out = celsius*9/5 + 32
239 case "K":
240 out = celsius + 273.15
241 }
242 return fmt.Sprintf("**%.1f°%s**\n\n- **%.1f°%s** is **%.1f°%s**",
243 out, to, c.amount, from, out, to), true
244}
245
246func convertUnits(c conversion) (string, bool) {
247 from, okF := findUnit(c.from)
248 to, okT := findUnit(c.to)
249 if !okF || !okT || from.family != to.family {
250 return "", false
251 }
252 out := c.amount * from.per / to.per
253 return fmt.Sprintf("**%s %s**\n\n- **%s %s** is **%s %s**",
254 trimNum(out), c.to, trimNum(c.amount), c.from, trimNum(out), c.to), true
255}
256
257// trimNum keeps enough precision to be useful without printing nine decimals
258// for a number a person is going to round anyway.
259func trimNum(v float64) string {
260 switch a := abs(v); {
261 case a >= 100:
262 return formatNumber(round2(v))
263 case a >= 1:
264 return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", v), "0"), ".")
265 default:
266 return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", v), "0"), ".")
267 }
268}