all repos — go-lift @ main

Lightweight workout tracker prototype..

src/database/exercises.go (view raw)

  1package database
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"log"
  8	"net/http"
  9	"strings"
 10	"time"
 11)
 12
 13// ImportedExercise represents the JSON structure from the input file
 14type importedExercise struct {
 15	Name             string   `json:"name"`
 16	Level            string   `json:"level"`
 17	Category         string   `json:"category"`
 18	Force            *string  `json:"force"`
 19	Mechanic         *string  `json:"mechanic"`
 20	Equipment        *string  `json:"equipment"`
 21	PrimaryMuscles   []string `json:"primaryMuscles"`
 22	SecondaryMuscles []string `json:"secondaryMuscles"`
 23	Instructions     []string `json:"instructions"`
 24	Images           []string `json:"images"`
 25	ID               string   `json:"id"`
 26}
 27
 28// upsertExercise creates or updates a single exercise with all its related data
 29func (db *Database) upsertExercise(importedExercise importedExercise) (didSave, isUpdate bool, err error) {
 30	// First, try to find existing exercise by name
 31	var existingExercise Exercise
 32	result := db.Where("name = ?", importedExercise.Name).Preload("PrimaryMuscles").Preload("SecondaryMuscles").First(&existingExercise)
 33
 34	// Create new exercise with basic info
 35	exercise := Exercise{
 36		Name:     importedExercise.Name,
 37		Level:    importedExercise.Level,
 38		Category: importedExercise.Category,
 39	}
 40
 41	if importedExercise.Force != nil && *importedExercise.Force != "" {
 42		exercise.Force = importedExercise.Force
 43	}
 44	if importedExercise.Mechanic != nil && *importedExercise.Mechanic != "" {
 45		exercise.Mechanic = importedExercise.Mechanic
 46	}
 47	if importedExercise.Equipment != nil && *importedExercise.Equipment != "" {
 48		exercise.Equipment = importedExercise.Equipment
 49	}
 50	if len(importedExercise.PrimaryMuscles) > 0 {
 51		primaryMuscles := strings.Join(importedExercise.PrimaryMuscles, ", ")
 52		exercise.PrimaryMuscles = &primaryMuscles
 53	}
 54	if len(importedExercise.SecondaryMuscles) > 0 {
 55		secondaryMuscles := strings.Join(importedExercise.SecondaryMuscles, ", ")
 56		exercise.SecondaryMuscles = &secondaryMuscles
 57	}
 58
 59	if len(importedExercise.Instructions) > 0 {
 60		// Filter out empty instructions
 61		var filteredInstructions []string
 62		for _, instruction := range importedExercise.Instructions {
 63			clean := strings.TrimSpace(instruction)
 64			if clean != "" {
 65				filteredInstructions = append(filteredInstructions, clean)
 66			}
 67		}
 68		instructions := strings.Join(filteredInstructions, "\n")
 69		exercise.Instructions = &instructions
 70	}
 71
 72	var exerciseDataChanged bool
 73
 74	if result.Error == nil {
 75		// Exercise exists, check if it needs updating
 76		isUpdate = true
 77		exercise.ID = existingExercise.ID
 78		exercise.CreatedAt = existingExercise.CreatedAt // Preserve creation time
 79
 80		// Check if the exercise data has actually changed
 81		exerciseDataChanged = db.exerciseDataChanged(existingExercise, exercise)
 82
 83		// Only update if something has changed
 84		if exerciseDataChanged {
 85			if err := db.Save(&exercise).Error; err != nil {
 86				return false, false, fmt.Errorf("failed to update exercise: %w", err)
 87			}
 88			didSave = true
 89		}
 90	} else {
 91		// Exercise doesn't exist, create it
 92		isUpdate = false
 93		exerciseDataChanged = true // New exercise, so data is "changed"
 94
 95		if err := db.Create(&exercise).Error; err != nil {
 96			return false, false, fmt.Errorf("failed to create exercise: %w", err)
 97		}
 98		didSave = true
 99	}
100
101	return
102}
103
104// exerciseDataChanged compares two exercises to see if core data has changed
105func (db *Database) exerciseDataChanged(existing, new Exercise) bool {
106	return existing.Level != new.Level ||
107		existing.Category != new.Category ||
108		!stringPointersEqual(existing.Force, new.Force) ||
109		!stringPointersEqual(existing.Mechanic, new.Mechanic) ||
110		!stringPointersEqual(existing.Equipment, new.Equipment) ||
111		!stringPointersEqual(existing.Instructions, new.Instructions) ||
112		existing.PrimaryMuscles != new.PrimaryMuscles ||
113		existing.SecondaryMuscles != new.SecondaryMuscles
114}
115
116// Helper function to compare string pointers
117func stringPointersEqual(a, b *string) bool {
118	if a == nil && b == nil {
119		return true
120	}
121	if a == nil || b == nil {
122		return false
123	}
124	return *a == *b
125}
126
127// downloadExercises downloads exercises from the JSON URL
128func downloadExercises() ([]importedExercise, error) {
129	// Download exercises.json from the URL
130	resp, err := http.Get(jsonURL)
131	if err != nil {
132		return nil, fmt.Errorf("failed to download exercises.json: %w", err)
133	}
134	defer resp.Body.Close()
135
136	// Check if the request was successful
137	if resp.StatusCode != http.StatusOK {
138		return nil, fmt.Errorf("failed to download exercises.json: HTTP status %d", resp.StatusCode)
139	}
140
141	// Read the response body
142	fileData, err := io.ReadAll(resp.Body)
143	if err != nil {
144		return nil, fmt.Errorf("failed to read response body: %w", err)
145	}
146
147	var exercises []importedExercise
148	if err := json.Unmarshal(fileData, &exercises); err != nil {
149		return nil, fmt.Errorf("failed to parse exercises.json: %w", err)
150	}
151
152	return exercises, nil
153}
154
155const (
156	dbDir       = "data"
157	dbName      = "fitness.sqlite"
158	baseURL     = "https://raw.githubusercontent.com/yuhonas/free-exercise-db/main/"
159	jsonURL     = baseURL + "dist/exercises.json"
160	imageFormat = baseURL + "exercises/%s/%d.jpg"
161	imageAmount = 2
162)
163
164var (
165	idReplacer = strings.NewReplacer(
166		" ", "_",
167		"/", "_",
168		",", "",
169		"(", "",
170		")", "",
171		"-", "-",
172		"'", "",
173	)
174	lastUpdate = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
175)
176
177func (e Exercise) StringID() string {
178	return idReplacer.Replace(e.Name)
179}
180
181func (e Exercise) Images() (images []string) {
182	id := e.StringID()
183
184	for i := range imageAmount {
185		images = append(images, fmt.Sprintf(imageFormat, id, i))
186	}
187	return
188}
189
190func (db *Database) UpdateExercises() (err error) {
191	// Load exercises
192	exercises, err := downloadExercises()
193	if err != nil {
194		log.Fatalf("Failed to load exercises: %v", err)
195	}
196
197	log.Printf("Successfully loaded %d exercises from JSON", len(exercises))
198
199	var successCount, createCount, updateCount int
200
201	// Import/update exercises
202	for i, exercise := range exercises {
203		didSave, isUpdate, err := db.upsertExercise(exercise)
204		if err != nil {
205			log.Printf("Failed to upsert exercise %d (%s): %v", i+1, exercise.Name, err)
206			continue
207		}
208
209		successCount++
210		if didSave {
211			if isUpdate {
212				updateCount++
213			} else {
214				createCount++
215			}
216		}
217	}
218
219	lastUpdate = time.Now()
220
221	log.Printf("Update completed successfully! Processed %d out of %d exercises (%d created, %d updated)", successCount, len(exercises), createCount, updateCount)
222	return
223}
224
225func (db *Database) GetLastUpdate() time.Time {
226	return lastUpdate
227}