484 lines
11 KiB
Go
484 lines
11 KiB
Go
package logbook
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.wow.st/gmp/logbook/parser"
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// --- Bucket names ---
|
|
|
|
var (
|
|
bucketSets = []byte("sets")
|
|
bucketSetGroups = []byte("setgroups")
|
|
bucketWorkouts = []byte("workouts")
|
|
)
|
|
|
|
// WorkoutRepository ---
|
|
|
|
type WorkoutRepository struct {
|
|
db *bolt.DB
|
|
}
|
|
|
|
func NewRepositories(dbPath string) (*WorkoutRepository, *MeasurementRepository, error) {
|
|
db, err := bolt.Open(dbPath, 0600, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
err = db.Update(func(tx *bolt.Tx) error {
|
|
_, err := tx.CreateBucketIfNotExists(bucketSets)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.CreateBucketIfNotExists(bucketSetGroups)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.CreateBucketIfNotExists(bucketWorkouts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &WorkoutRepository{db: db}, &MeasurementRepository{db: db}, nil
|
|
}
|
|
|
|
func (wr *WorkoutRepository) Close() {
|
|
wr.db.Close()
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
func getStruct(b *bolt.Bucket, id string, v interface{}) error {
|
|
if b == nil {
|
|
return errors.New("bucket does not exist")
|
|
}
|
|
data := b.Get([]byte(id))
|
|
if data == nil {
|
|
return errors.New("not found")
|
|
}
|
|
return json.Unmarshal(data, v)
|
|
}
|
|
|
|
func putStruct(b *bolt.Bucket, id string, v interface{}) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.Put([]byte(id), data)
|
|
}
|
|
|
|
func deleteStruct(b *bolt.Bucket, id string) error {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
return b.Delete([]byte(id))
|
|
}
|
|
|
|
// --- ID generation (RFC3339 date + context + random suffix) ---
|
|
|
|
func generateDateID(date time.Time, suffix string) string {
|
|
base := date.UTC().Format("2006-01-02T150405Z07")
|
|
if suffix != "" {
|
|
base += "-" + sanitizeForID(suffix)
|
|
}
|
|
randBytes := make([]byte, 4)
|
|
_, _ = rand.Read(randBytes)
|
|
return fmt.Sprintf("%s-%s", base, hex.EncodeToString(randBytes))
|
|
}
|
|
|
|
func sanitizeForID(s string) string {
|
|
out := ""
|
|
for _, ch := range s {
|
|
if ch == ' ' || ch == '/' || ch == '\\' {
|
|
out += "_"
|
|
} else {
|
|
out += string(ch)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- Data Structures (for DB storage) ---
|
|
|
|
type Set struct {
|
|
ID string
|
|
Weight float64
|
|
Reps int
|
|
Note string
|
|
Time time.Time
|
|
RIR int
|
|
}
|
|
|
|
type SetGroup struct {
|
|
ID string
|
|
Exercise string
|
|
Type string
|
|
Note string
|
|
PlannedSetIDs []string
|
|
ActualSetIDs []string
|
|
}
|
|
|
|
type Workout struct {
|
|
ID string
|
|
Date time.Time
|
|
Type string
|
|
Note string
|
|
SetGroupIDs []string
|
|
}
|
|
|
|
// --- AddWorkoutsFromIR (with bucket creation) ---
|
|
|
|
func (r *WorkoutRepository) AddWorkoutsFromIR(irWorkouts []parser.Workout) error {
|
|
db := r.db
|
|
if db == nil {
|
|
return fmt.Errorf("Workout Repository is not initialized.")
|
|
}
|
|
return db.Update(func(tx *bolt.Tx) error {
|
|
// Ensure buckets exist
|
|
setsB, err := tx.CreateBucketIfNotExists(bucketSets)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
setGroupsB, err := tx.CreateBucketIfNotExists(bucketSetGroups)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
workoutsB, err := tx.CreateBucketIfNotExists(bucketWorkouts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, irW := range irWorkouts {
|
|
workoutID := generateDateID(irW.Date, "")
|
|
w := &Workout{
|
|
ID: workoutID,
|
|
Date: irW.Date,
|
|
Type: irW.Type,
|
|
Note: irW.Note,
|
|
SetGroupIDs: nil,
|
|
}
|
|
|
|
for gidx, irG := range irW.SetGroups {
|
|
setGroupID := generateDateID(irW.Date, fmt.Sprintf("%s-%d", irG.Exercise, gidx))
|
|
sg := &SetGroup{
|
|
ID: setGroupID,
|
|
Exercise: irG.Exercise,
|
|
Type: irG.Type,
|
|
Note: irG.Note,
|
|
PlannedSetIDs: nil,
|
|
ActualSetIDs: nil,
|
|
}
|
|
|
|
// Add Planned Sets
|
|
for i, irSet := range irG.PlannedSets {
|
|
setID := generateDateID(irW.Date, fmt.Sprintf("%s-planned-%d", irG.Exercise, i))
|
|
set := &Set{
|
|
ID: setID,
|
|
Weight: irSet.Weight,
|
|
Reps: irSet.Reps,
|
|
Note: irSet.Note,
|
|
Time: irSet.Time,
|
|
RIR: irSet.RIR,
|
|
}
|
|
if err := putStruct(setsB, set.ID, set); err != nil {
|
|
return fmt.Errorf("failed to store planned set: %w", err)
|
|
}
|
|
sg.PlannedSetIDs = append(sg.PlannedSetIDs, set.ID)
|
|
}
|
|
|
|
// Add Actual Sets
|
|
for i, irSet := range irG.ActualSets {
|
|
setID := generateDateID(irW.Date, fmt.Sprintf("%s-actual-%d", irG.Exercise, i))
|
|
set := &Set{
|
|
ID: setID,
|
|
Weight: irSet.Weight,
|
|
Reps: irSet.Reps,
|
|
Note: irSet.Note,
|
|
Time: irSet.Time,
|
|
RIR: irSet.RIR,
|
|
}
|
|
if err := putStruct(setsB, set.ID, set); err != nil {
|
|
return fmt.Errorf("failed to store actual set: %w", err)
|
|
}
|
|
sg.ActualSetIDs = append(sg.ActualSetIDs, set.ID)
|
|
}
|
|
|
|
// Store SetGroup
|
|
if err := putStruct(setGroupsB, sg.ID, sg); err != nil {
|
|
return fmt.Errorf("failed to store set group: %w", err)
|
|
}
|
|
w.SetGroupIDs = append(w.SetGroupIDs, sg.ID)
|
|
}
|
|
|
|
// Store Workout
|
|
if err := putStruct(workoutsB, w.ID, w); err != nil {
|
|
return fmt.Errorf("failed to store workout: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// --- GET METHODS ---
|
|
|
|
func (r *WorkoutRepository) GetWorkout(id string) (*parser.Workout, error) {
|
|
db := r.db
|
|
var w Workout
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketWorkouts)
|
|
if b == nil {
|
|
return errors.New("workouts bucket does not exist")
|
|
}
|
|
return getStruct(b, id, &w)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ir := &parser.Workout{
|
|
Date: w.Date,
|
|
Type: w.Type,
|
|
Note: w.Note,
|
|
SetGroups: nil,
|
|
}
|
|
for _, sgid := range w.SetGroupIDs {
|
|
sg, err := r.GetSetGroup(sgid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get setgroup: %w", err)
|
|
}
|
|
ir.SetGroups = append(ir.SetGroups, *sg)
|
|
}
|
|
return ir, nil
|
|
}
|
|
|
|
func (r *WorkoutRepository) GetSetGroup(id string) (*parser.SetGroup, error) {
|
|
db := r.db
|
|
var sg SetGroup
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketSetGroups)
|
|
if b == nil {
|
|
return errors.New("setgroups bucket does not exist")
|
|
}
|
|
return getStruct(b, id, &sg)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ir := &parser.SetGroup{
|
|
Exercise: sg.Exercise,
|
|
Type: sg.Type,
|
|
Note: sg.Note,
|
|
PlannedSets: nil,
|
|
ActualSets: nil,
|
|
}
|
|
for _, setid := range sg.PlannedSetIDs {
|
|
s, err := r.GetSet(setid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get planned set: %w", err)
|
|
}
|
|
ir.PlannedSets = append(ir.PlannedSets, *s)
|
|
}
|
|
for _, setid := range sg.ActualSetIDs {
|
|
s, err := r.GetSet(setid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get actual set: %w", err)
|
|
}
|
|
ir.ActualSets = append(ir.ActualSets, *s)
|
|
}
|
|
return ir, nil
|
|
}
|
|
|
|
func (r *WorkoutRepository) GetSet(id string) (*parser.Set, error) {
|
|
db := r.db
|
|
var s Set
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketSets)
|
|
if b == nil {
|
|
return errors.New("sets bucket does not exist")
|
|
}
|
|
return getStruct(b, id, &s)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &parser.Set{
|
|
Weight: s.Weight,
|
|
Reps: s.Reps,
|
|
Note: s.Note,
|
|
Time: s.Time,
|
|
RIR: s.RIR,
|
|
}, nil
|
|
}
|
|
|
|
// --- FIND METHODS ---
|
|
|
|
// FindWorkouts returns a slice of workouts in parser.Workout format
|
|
// (intermediate representation) as well as a slice of workout IDs.
|
|
func (r *WorkoutRepository) FindWorkouts(match func(*parser.Workout) bool) ([]*parser.Workout, []string, error) {
|
|
db := r.db
|
|
var ws []*parser.Workout
|
|
var ids []string
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketWorkouts)
|
|
if b == nil {
|
|
// No workouts bucket yet; return empty list, no error
|
|
return nil
|
|
}
|
|
return b.ForEach(func(k, v []byte) error {
|
|
var w Workout
|
|
if err := json.Unmarshal(v, &w); err != nil {
|
|
return err
|
|
}
|
|
ir := &parser.Workout{
|
|
Date: w.Date,
|
|
Type: w.Type,
|
|
Note: w.Note,
|
|
SetGroups: nil,
|
|
}
|
|
for _, sgid := range w.SetGroupIDs {
|
|
sg, err := r.GetSetGroup(sgid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ir.SetGroups = append(ir.SetGroups, *sg)
|
|
}
|
|
if match(ir) {
|
|
ws = append(ws, ir)
|
|
ids = append(ids, w.ID)
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
return ws, ids, err
|
|
}
|
|
|
|
func (r *WorkoutRepository) FindSetGroups(match func(*parser.SetGroup) bool) ([]*parser.SetGroup, error) {
|
|
db := r.db
|
|
var out []*parser.SetGroup
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketSetGroups)
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
return b.ForEach(func(k, v []byte) error {
|
|
var sg SetGroup
|
|
if err := json.Unmarshal(v, &sg); err != nil {
|
|
return err
|
|
}
|
|
ir := &parser.SetGroup{
|
|
Exercise: sg.Exercise,
|
|
Type: sg.Type,
|
|
Note: sg.Note,
|
|
PlannedSets: nil,
|
|
ActualSets: nil,
|
|
}
|
|
for _, setid := range sg.PlannedSetIDs {
|
|
s, err := r.GetSet(setid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ir.PlannedSets = append(ir.PlannedSets, *s)
|
|
}
|
|
for _, setid := range sg.ActualSetIDs {
|
|
s, err := r.GetSet(setid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ir.ActualSets = append(ir.ActualSets, *s)
|
|
}
|
|
if match(ir) {
|
|
out = append(out, ir)
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
func (r *WorkoutRepository) FindSets(match func(*parser.Set) bool) ([]*parser.Set, error) {
|
|
db := r.db
|
|
var out []*parser.Set
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketSets)
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
return b.ForEach(func(k, v []byte) error {
|
|
var s Set
|
|
if err := json.Unmarshal(v, &s); err != nil {
|
|
return err
|
|
}
|
|
ir := &parser.Set{
|
|
Weight: s.Weight,
|
|
Reps: s.Reps,
|
|
Note: s.Note,
|
|
Time: s.Time,
|
|
}
|
|
if match(ir) {
|
|
out = append(out, ir)
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// --- UPDATE METHODS (stubs: see previous answer for logic) ---
|
|
|
|
func (r *WorkoutRepository) UpdateWorkout(updated *parser.Workout) error {
|
|
return errors.New("UpdateWorkout not implemented")
|
|
}
|
|
|
|
func (r *WorkoutRepository) UpdateSetGroup(updated *parser.SetGroup) error {
|
|
return errors.New("UpdateSetGroup not implemented")
|
|
}
|
|
|
|
func (r *WorkoutRepository) UpdateSet(updated *parser.Set) error {
|
|
return errors.New("UpdateSet not implemented")
|
|
}
|
|
|
|
// --- DELETE METHOD ---
|
|
|
|
func (r *WorkoutRepository) DeleteWorkout(id string) error {
|
|
db := r.db
|
|
return db.Update(func(tx *bolt.Tx) error {
|
|
bW := tx.Bucket(bucketWorkouts)
|
|
if bW == nil {
|
|
return errors.New("workouts bucket does not exist")
|
|
}
|
|
var w Workout
|
|
if err := getStruct(bW, id, &w); err != nil {
|
|
return err
|
|
}
|
|
bSG := tx.Bucket(bucketSetGroups)
|
|
bS := tx.Bucket(bucketSets)
|
|
for _, sgid := range w.SetGroupIDs {
|
|
var sg SetGroup
|
|
if bSG != nil {
|
|
if err := getStruct(bSG, sgid, &sg); err != nil {
|
|
continue
|
|
}
|
|
for _, setid := range sg.PlannedSetIDs {
|
|
_ = deleteStruct(bS, setid)
|
|
}
|
|
for _, setid := range sg.ActualSetIDs {
|
|
_ = deleteStruct(bS, setid)
|
|
}
|
|
_ = deleteStruct(bSG, sgid)
|
|
}
|
|
}
|
|
return deleteStruct(bW, id)
|
|
})
|
|
}
|
|
|