Compare commits

...

2 Commits

Author SHA1 Message Date
095cd72946 Add rowing machine endpoint 2026-03-04 14:22:05 +00:00
1d4beca336 Add claude client to store 2026-03-04 14:21:51 +00:00
8 changed files with 221 additions and 4 deletions

View File

@@ -7,12 +7,13 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/zmb3/spotify/v2 v2.4.3
golang.org/x/crypto v0.43.0
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5
golang.org/x/oauth2 v0.30.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -23,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
@@ -40,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

View File

@@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@@ -102,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=
@@ -172,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=
@@ -183,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=
@@ -287,6 +303,8 @@ golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4Iltr
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@@ -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)
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"adam-french.co.uk/backend/services"
"github.com/anthropics/anthropic-sdk-go"
"github.com/zmb3/spotify/v2"
spotifyauth "github.com/zmb3/spotify/v2/auth"
"gorm.io/gorm"
@@ -13,6 +14,7 @@ type Store struct {
DB *gorm.DB
SpotifyAuth *spotifyauth.Authenticator
SpotifyClient *spotify.Client
ClaudeClient *anthropic.Client
Auth *services.Auth
Notes *services.Notes

View File

@@ -40,12 +40,18 @@ func main() {
log.Fatal(err)
}
// SPOTIFY
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
spotifyRedirectURL := os.Getenv("SPOTIFY_REDIRECT_URI")
spotifyClientID := os.Getenv("SPOTIFY_CLIENT_ID")
spotifyClientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
spotifyConfig := services.SpotifyConfig{AuthState: spotifyAuthState, RedirectURL: spotifyRedirectURL, ClientID: spotifyClientID, ClientSecret: spotifyClientSecret}
spotifyAuth, client := services.InitSpotifyAuth(&spotifyConfig)
spotifyAuth, spotifyClient := services.InitSpotifyAuth(&spotifyConfig)
// CLAUDE
claudeAPIKey := os.Getenv("CLAUDE_API_KEY")
claudeConfig := services.ClaudeConfig{APIKey: claudeAPIKey}
claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
@@ -59,7 +65,7 @@ func main() {
notesConfig := services.NotesConfig{Dir: notesDir}
notes := services.InitNotes(&notesConfig)
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: client, Auth: auth, Notes: notes}
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
protected := r.Group("/", store.AuthMiddlewear)
@@ -67,6 +73,10 @@ func main() {
r.GET("/favorites", store.GetFavorites)
protected.POST("/favorites", store.CreateFavorite)
// ROWING
r.GET("/rowing", store.GetRowing)
protected.POST("/rowing", store.CreateRowing)
// ACTIVITIES
r.GET("/activity", store.GetActivity)
protected.POST("/activity", store.CreateActivity)

View File

@@ -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"`
}

View File

@@ -0,0 +1,15 @@
package services
import (
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
)
type ClaudeConfig struct {
APIKey string
}
func InitClaude(config *ClaudeConfig) *anthropic.Client {
client := anthropic.NewClient(option.WithAPIKey(config.APIKey))
return &client
}

View File

@@ -36,6 +36,7 @@ func migrateDatabase(db *gorm.DB) error {
&models.Post{},
&models.Activity{},
&models.Favorite{},
&models.Rowing{},
)
if err != nil {
return err