logbook/parser/parser.go
2025-05-09 14:37:57 -04:00

372 lines
9.5 KiB
Go

package parser
import (
"fmt"
"strconv"
"strings"
"time"
)
// ParseDSL parses the whitespace-structured DSL and returns a slice of Workouts and a slice of Measurements.
func ParseDSL(input string) ([]Workout, []Measurement, error) {
lines := strings.Split(input, "\n")
var workouts []Workout
var measurements []Measurement
var curWorkout *Workout
var curSetGroup *SetGroup
for idx, rawLine := range lines {
line := strings.TrimRight(rawLine, "\r\n")
if line == "" {
continue
}
indent := countIndent(line)
content := strings.TrimLeft(line, " \t")
var setIndex int
switch indent {
case 0:
// either a workout or a measurement
parts := strings.SplitN(line, " ", 3)
if (parts[1] == "measurement") {
// Measurement line: date measurement Variable=Value [note]
date, variable, value, note, err := parseMeasurementLine(content)
if err != nil {
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
}
measurements = append(measurements, Measurement{
Date: date,
Variable: variable,
Value: value,
Note: note,
})
} else if (parts[1] == "workout") {
// Workout line: date workout [type in (parentheses)] [note]
date, typ, note, err := parseWorkoutLine(content)
if err != nil {
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
}
workouts = append(workouts, Workout{
Date: date,
Type: typ,
Note: note,
SetGroups: nil,
})
curWorkout = &workouts[len(workouts)-1]
curSetGroup = nil
} else {
return nil, nil, fmt.Errorf("Invalid top level item: %s.\n",parts[1])
}
case 1:
// SetGroup line (including planned sets)
if curWorkout == nil {
return nil, nil, fmt.Errorf("line %d: SetGroup without Workout", idx+1)
}
sg, err := parseSetGroupLine(content)
if err != nil {
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
}
curWorkout.SetGroups = append(curWorkout.SetGroups, sg)
curSetGroup = &curWorkout.SetGroups[len(curWorkout.SetGroups)-1]
setIndex = 0
case 2:
// Actual Set line (may be multiple sets separated by "-")
if curSetGroup == nil {
return nil, nil, fmt.Errorf("line %d: Actual Set without SetGroup", idx+1)
}
sets, weights, err := parseActualSetLine(content,curWorkout.Date)
if err != nil {
return nil, nil, fmt.Errorf("line %d: %v", idx+1, err)
}
for i := range(sets) {
if !weights[i] {
sets[i].Weight = curSetGroup.PlannedSets[setIndex].Weight
}
if setIndex < len(curSetGroup.PlannedSets) -1 {
setIndex++
}
}
curSetGroup.ActualSets = append(curSetGroup.ActualSets, sets...)
default:
return nil, nil, fmt.Errorf("line %d: Unexpected indentation", idx+1)
}
}
return workouts, measurements, nil
}
// countIndent returns the number of leading whitespace "levels" (spaces or tabs).
// 0 = Workout, 1 = SetGroup, 2 = ActualSet.
func countIndent(line string) int {
indent := 0
for _, ch := range line {
if ch == ' ' || ch == '\t' {
indent++
} else {
break
}
}
return indent
}
// parseMeasurementLine splits the Measurement line into the date, variable,
// value, and note fields.
func parseMeasurementLine(line string) (time.Time, string, float64, string, error) {
parts := strings.SplitN(line, " ", 3)
dateStr := parts[0]
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return time.Time{}, "", 0, "", fmt.Errorf("invalid date: %v", err)
}
parts = strings.SplitN(parts[2],"=",2)
variable := parts[0]
value, _, err := parseWeightAndUnit(parts[1])
if err != nil {
return time.Time{}, "", 0, "", fmt.Errorf("Cannot parse measurement value.\n")
}
note := ""
if len(parts) > 2 {
note = parts[3]
}
return date, variable, value, note, nil
}
// parseWorkoutLine splits the Workout line into date, type, note.
// Date is the first non-whitespace field, type is in balanced parentheses, note is the rest.
func parseWorkoutLine(line string) (time.Time, string, string, error) {
parts := strings.SplitN(line, " ", 3)
dateStr := parts[0]
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return time.Time{}, "", "", fmt.Errorf("invalid date: %v", err)
}
typ := ""
note := ""
if len(parts) == 3 {
rest := strings.TrimSpace(parts[2])
// Look for type in balanced parentheses
if strings.HasPrefix(rest, "(") {
depth := 0
endIdx := -1
for i, ch := range rest {
if ch == '(' {
depth++
} else if ch == ')' {
depth--
if depth == 0 {
endIdx = i
break
}
}
}
if endIdx != -1 {
typ = strings.TrimSpace(rest[1:endIdx])
note = strings.TrimSpace(rest[endIdx+1:])
} else {
// Unbalanced, treat as note
note = rest
}
} else {
note = rest
}
}
return date, typ, note, nil
}
// parseSetGroupLine parses a SetGroup line.
// The first field(s) are the exercise (may contain spaces, but never a number).
// The next field is the weight, then setsxreps, then optional type (in parentheses), then optional note.
func parseSetGroupLine(line string) (SetGroup, error) {
fields := strings.Fields(line)
if len(fields) < 3 {
return SetGroup{}, fmt.Errorf("not enough fields for SetGroup")
}
// Find where the weight starts (first field with a digit)
exerciseFields := []string{}
weightIdx := -1
for i, f := range fields {
if hasDigit(f) {
weightIdx = i
break
}
exerciseFields = append(exerciseFields, f)
}
if weightIdx == -1 || weightIdx+2 > len(fields) {
return SetGroup{}, fmt.Errorf("no weight or setsxreps found in SetGroup line")
}
exercise := strings.Join(exerciseFields, " ")
weightField := fields[weightIdx]
setsxrepsField := fields[weightIdx+1]
// Parse weight and optional unit
weight, unit, err := parseWeightAndUnit(weightField)
if err != nil {
return SetGroup{}, fmt.Errorf("invalid weight: %v", err)
}
if unit == "kg" {
weight = weight * 2.20462 // convert to pounds
}
// Parse setsxreps (e.g. 5x3)
sets, reps, err := parseSetsXReps(setsxrepsField)
if err != nil {
return SetGroup{}, fmt.Errorf("invalid setsxreps: %v", err)
}
// Remaining fields: optional type (in parentheses), then optional note
typ := ""
note := ""
rest := strings.Join(fields[weightIdx+2:], " ")
rest = strings.TrimSpace(rest)
if strings.HasPrefix(rest, "(") {
// Find end of type in parentheses
depth := 0
endIdx := -1
for i, ch := range rest {
if ch == '(' {
depth++
} else if ch == ')' {
depth--
if depth == 0 {
endIdx = i
break
}
}
}
if endIdx != -1 {
typ = strings.TrimSpace(rest[1:endIdx])
note = strings.TrimSpace(rest[endIdx+1:])
} else {
// Unbalanced, treat as note
note = rest
}
} else {
note = rest
}
// Build planned sets
plannedSets := make([]Set, sets)
for i := 0; i < sets; i++ {
plannedSets[i] = Set{
Weight: weight,
Reps: reps,
}
}
return SetGroup{
Exercise: exercise,
Type: typ,
Note: note,
PlannedSets: plannedSets,
ActualSets: nil,
}, nil
}
func hasDigit(s string) bool {
for _, ch := range s {
if ch >= '0' && ch <= '9' {
return true
}
}
return false
}
// parseWeightAndUnit parses a weight field, e.g. "100kg", "225", "225lb"
func parseWeightAndUnit(field string) (float64, string, error) {
// Find where the first non-digit or non-dot occurs
i := 0
for ; i < len(field); i++ {
if (field[i] < '0' || field[i] > '9') && field[i] != '.' {
break
}
}
weightStr := field[:i]
unit := strings.ToLower(field[i:])
weight, err := strconv.ParseFloat(weightStr, 64)
if err != nil {
return 0, "", err
}
return weight, unit, nil
}
// parseSetsXReps parses a setsxreps field, e.g. "5x3"
func parseSetsXReps(field string) (int, int, error) {
parts := strings.Split(field, "x")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("expected format setsxreps, got %q", field)
}
sets, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid sets: %v", err)
}
reps, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid reps: %v", err)
}
return sets, reps, nil
}
// parseActualSetLine parses an Actual Set line (can be multiple sets separated by "-").
// The first field is set data (possibly with "-"), the rest is an optional note.
func parseActualSetLine(line string, date time.Time) ([]Set, []bool, error) {
parts := strings.SplitN(line, " ", 2)
setsPart := parts[0]
note := ""
if len(parts) == 2 {
note = strings.TrimSpace(parts[1])
}
setStrs := strings.Split(setsPart, "-")
sets := []Set{}
weights := []bool{}
for _, s := range setStrs {
s = strings.TrimSpace(s)
if s == "" {
continue
}
// Parse as either "reps", "weightxreps", "weightunitxreps"
weight := 0.0
reps := 0
if strings.Contains(s, "x") {
parts := strings.SplitN(s, "x", 2)
w, u, err := parseWeightAndUnit(parts[0])
if err != nil {
return nil, nil, fmt.Errorf("invalid weight in actual set: %v", err)
}
if u == "kg" {
w = w * 2.20462
}
weights = append(weights,true)
weight = w
reps, err = strconv.Atoi(parts[1])
if err != nil {
return nil, nil, fmt.Errorf("invalid reps in actual set: %v", err)
}
} else {
// Just reps, no weight
var err error
reps, err = strconv.Atoi(s)
if err != nil {
return nil, nil, fmt.Errorf("invalid reps in actual set: %v", err)
}
weights = append(weights,false)
}
sets = append(sets, Set{
Weight: weight,
Reps: reps,
Note: "",
Time: date,
RIR: -1,
})
}
if note != "" && len(sets) > 0 {
sets[len(sets)-1].Note = note
if strings.HasPrefix(note, "max") {
sets[len(sets)-1].RIR = 0
}
}
return sets, weights, nil
}