first commit

This commit is contained in:
Blake Ridgway
2026-04-11 14:06:59 -05:00
commit ba1770b493
21 changed files with 2027 additions and 0 deletions

77
parser/parser.go Normal file
View File

@@ -0,0 +1,77 @@
// Package parser extracts cycling distances from free-text Discord messages.
package parser
import (
"regexp"
"strconv"
"strings"
)
const miToKM = 1.60934
var (
// Matches: 25km, 25.5 km, 25,5km, 25KM, 25 kilometers, 25 kilometres
kmPattern = regexp.MustCompile(
`(?i)\b(\d+(?:[.,]\d+)?)\s*(?:km|kms|kilometer|kilometers|kilometre|kilometres)\b`,
)
// Matches standalone "k" used in cycling context: "did a 100k", "50k ride"
// Only match when followed by a word boundary and a non-unit word (ride, loop, etc.)
// or preceded by cycling verbs.
kPattern = regexp.MustCompile(
`(?i)\b(\d+(?:[.,]\d+)?)\s*k\b`,
)
// Matches: 25mi, 25 mi, 25 miles, 25mile
miPattern = regexp.MustCompile(
`(?i)\b(\d+(?:[.,]\d+)?)\s*(?:mi|mile|miles)\b`,
)
)
// ParseKM extracts the first distance found in text and returns it in KM.
// Returns 0, false if no distance could be parsed.
func ParseKM(text string) (float64, bool) {
if km, ok := firstMatch(kmPattern, text, 1.0); ok {
return km, true
}
if km, ok := firstMatch(miPattern, text, miToKM); ok {
return km, true
}
// "k" alone is ambiguous; only accept it when the message looks cycling-related
if looksLikeCycling(text) {
if km, ok := firstMatch(kPattern, text, 1.0); ok {
return km, true
}
}
return 0, false
}
func firstMatch(re *regexp.Regexp, text string, multiplier float64) (float64, bool) {
m := re.FindStringSubmatch(text)
if m == nil {
return 0, false
}
// Normalise comma decimal separator
numStr := strings.ReplaceAll(m[1], ",", ".")
v, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0, false
}
return v * multiplier, true
}
var cyclingKeywords = []string{
"ride", "rode", "cycl", "bike", "biked", "biking", "cycle",
"zwift", "strava", "trainer", "gravel", "mtb", "road", "spin",
"century", "loop", "route", "segment", "climb", "climbing",
}
func looksLikeCycling(text string) bool {
lower := strings.ToLower(text)
for _, kw := range cyclingKeywords {
if strings.Contains(lower, kw) {
return true
}
}
return false
}

41
parser/parser_test.go Normal file
View File

@@ -0,0 +1,41 @@
package parser
import (
"math"
"testing"
)
func TestParseKM(t *testing.T) {
tests := []struct {
input string
wantKM float64
wantOK bool
}{
{"Rode 25km today!", 25, true},
{"Great 25.5 km ride", 25.5, true},
{"Did 25,5km on Zwift", 25.5, true},
{"50KM morning loop", 50, true},
{"100 kilometers on the gravel bike", 100, true},
{"30 miles today", 30 * miToKM, true},
{"Did a 100k ride", 100, true},
{"Went for a 80k loop", 80, true},
// Should NOT match — no cycling context for bare "k"
{"Listened to 100k songs", 0, false},
// No distance at all
{"Great weather today!", 0, false},
{"PR on the climb!", 0, false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, ok := ParseKM(tt.input)
if ok != tt.wantOK {
t.Errorf("ParseKM(%q) ok=%v, want %v", tt.input, ok, tt.wantOK)
return
}
if tt.wantOK && math.Abs(got-tt.wantKM) > 0.01 {
t.Errorf("ParseKM(%q) = %.2f, want %.2f", tt.input, got, tt.wantKM)
}
})
}
}