repos
/ orchard main

orchard

mirror

Every 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

8.8 KB · 269 lines · Go Raw History
  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}