372 lines
9.5 KiB
Go
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
|
|
}
|
|
|