diff --git a/backend/go.mod b/backend/go.mod index 7f13ee9..9a73245 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,7 +24,7 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -41,6 +41,11 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cfcba74..29fe44d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -104,6 +104,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -174,6 +176,8 @@ github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1 github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -185,6 +189,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= diff --git a/backend/handlers/handle_rowing.go b/backend/handlers/handle_rowing.go new file mode 100644 index 0000000..07e9002 --- /dev/null +++ b/backend/handlers/handle_rowing.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/rwcarlsen/goexif/exif" + + "adam-french.co.uk/backend/models" + "github.com/anthropics/anthropic-sdk-go" + "github.com/gin-gonic/gin" +) + +type ExtractedRowingData struct { + TimeMinutes float64 `json:"timeMinutes"` + TimeSeconds float64 `json:"timeSeconds"` + Distance float64 `json:"distance"` +} + +func (store *Store) GetRowing(ctx *gin.Context) { + var rowing []models.Rowing + if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, err.Error()) + return + } + ctx.JSON(http.StatusOK, rowing) +} + +func (store *Store) CreateRowing(ctx *gin.Context) { + file, err := ctx.FormFile("image") + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "image is required"}) + return + } + + f, err := file.Open() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open image"}) + return + } + defer f.Close() + + // Get the date taken from the EXIF data + x, err := exif.Decode(f) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "no EXIF data found"}) + return + } + + dateTaken, err := x.DateTime() + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "no date found in EXIF data"}) + return + } + + // Seek back to start since exif.Decode advanced the cursor + if _, err := f.Seek(0, io.SeekStart); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek image"}) + return + } + + data, err := io.ReadAll(f) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read image"}) + return + } + + allowedMediaTypes := map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + } + mediaType := file.Header.Get("Content-Type") + if !allowedMediaTypes[mediaType] { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image type"}) + return + } + encoded := base64.StdEncoding.EncodeToString(data) + + // Build the message with an image + text prompt + message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: anthropic.ModelClaudeHaiku4_5, + MaxTokens: 256, + Messages: []anthropic.MessageParam{ + { + Role: "user", + Content: []anthropic.ContentBlockParamUnion{ + // Image block + anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{ + Type: "base64", + MediaType: anthropic.Base64ImageSourceMediaType(mediaType), + Data: encoded, + }), + // Text prompt requesting exactly 2 variables + anthropic.NewTextBlock( + `Look at this rowing machine display. Extract the total elapsed time and total distance. + +Return ONLY a JSON object with these exact keys and numeric values: +- "timeMinutes": total minutes (e.g. 2:30 = 2) +- "timeSeconds": total seconds (e.g. 2:30 = 30) +- "distance": distance in meters as a number (e.g. 5000) + +No text, no markdown, no explanation. Just the JSON object.`), + }, + }, + }, + }) + + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"}) + return + } + + if len(message.Content) == 0 { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"}) + return + } + + extractedData := ExtractedRowingData{} + err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"}) + return + } + + if extractedData.Distance == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid distance in image"}) + return + } + + totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds + totalDuration := time.Duration(totalSeconds * float64(time.Second)) + per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second)) + + rowing := models.Rowing{ + Date: dateTaken, + Time: totalDuration, + TimePer500m: per500m, + Distance: extractedData.Distance, + Calories: extractedData.Distance / 7500.0 * 500.0, + } + + if err := store.DB.Create(&rowing).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save rowing"}) + return + } + + ctx.JSON(http.StatusCreated, rowing) +} diff --git a/backend/models/models.go b/backend/models/models.go index 4f43ead..f6b47b3 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -55,3 +55,14 @@ type Favorite struct { Name string `json:"name"` Link *string `json:"link"` } + +type Rowing struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` + Date time.Time `json:"date"` + Time time.Duration `json:"time"` + TimePer500m time.Duration `json:"timePer500m"` + Distance float64 `json:"distance"` + Calories float64 `json:"calories"` +} diff --git a/backend/services/database.go b/backend/services/database.go index 92b61dd..96641a1 100644 --- a/backend/services/database.go +++ b/backend/services/database.go @@ -36,6 +36,7 @@ func migrateDatabase(db *gorm.DB) error { &models.Post{}, &models.Activity{}, &models.Favorite{}, + &models.Rowing{}, ) if err != nil { return err