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}