mirror of
https://github.com/sstent/garminsync-go.git
synced 2025-12-06 08:01:52 +00:00
checkpoint 1
This commit is contained in:
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache gcc musl-dev git
|
||||||
|
|
||||||
|
# Enable Go Modules
|
||||||
|
ENV GO111MODULE=on
|
||||||
|
|
||||||
|
# Copy module files first for efficient caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o garminsync .
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:3.18
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/garminsync /app/garminsync
|
||||||
|
|
||||||
|
# Copy templates
|
||||||
|
COPY internal/web/templates ./internal/web/templates
|
||||||
|
|
||||||
|
# Set timezone and environment
|
||||||
|
ENV TZ=UTC \
|
||||||
|
DATA_DIR=/data \
|
||||||
|
DB_PATH=/data/garmin.db \
|
||||||
|
TEMPLATE_DIR=/app/internal/web/templates
|
||||||
|
|
||||||
|
# Create data volume and set permissions
|
||||||
|
RUN mkdir /data && chown nobody:nobody /data
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
# Run as non-root user
|
||||||
|
USER nobody
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --retries=3 \
|
||||||
|
CMD wget --quiet --tries=1 --spider http://localhost:8888/health || exit 1
|
||||||
|
|
||||||
|
# Expose web port
|
||||||
|
EXPOSE 8888
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
ENTRYPOINT ["/app/garminsync"]
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
garminsync:
|
||||||
|
build: .
|
||||||
|
container_name: garminsync
|
||||||
|
ports:
|
||||||
|
- "8888:8888"
|
||||||
|
environment:
|
||||||
|
- GARMIN_EMAIL=${GARMIN_EMAIL}
|
||||||
|
- GARMIN_PASSWORD=${GARMIN_PASSWORD}
|
||||||
|
- DATA_DIR=/data
|
||||||
|
- DB_PATH=/data/garmin.db
|
||||||
|
- TEMPLATE_DIR=/app/internal/web/templates
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
- ./internal/web/templates:/app/internal/web/templates
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 30s # Increased timeout for startup
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
27
go.mod
27
go.mod
@@ -4,8 +4,27 @@ module garminsync
|
|||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.17
|
github.com/gorilla/mux v1.8.0 // For HTTP routing
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/mattn/go-sqlite3 v1.14.17
|
||||||
github.com/gorilla/mux v1.8.0 // For HTTP routing
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/net v0.12.0 // For HTTP client
|
golang.org/x/net v0.12.0 // For HTTP client
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||||
|
github.com/client9/misspell v0.3.4 // indirect
|
||||||
|
github.com/google/go-cmp v0.5.8 // indirect
|
||||||
|
github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect
|
||||||
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/kisielk/errcheck v1.6.1 // indirect
|
||||||
|
github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b // indirect
|
||||||
|
github.com/tormoder/fit v0.15.0 // indirect
|
||||||
|
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect
|
||||||
|
golang.org/x/mod v0.8.0 // indirect
|
||||||
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
|
golang.org/x/sys v0.10.0 // indirect
|
||||||
|
golang.org/x/text v0.11.0 // indirect
|
||||||
|
golang.org/x/tools v0.6.0 // indirect
|
||||||
|
honnef.co/go/tools v0.4.2 // indirect
|
||||||
|
mvdan.cc/gofumpt v0.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
117
go.sum
Normal file
117
go.sum
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
|
||||||
|
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
|
github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
|
||||||
|
github.com/bradfitz/latlong v0.0.0-20170410180902-f3db6d0dff40/go.mod h1:ZcXX9BndVQx6Q/JM6B8x7dLE9sl20S+TQsv4KO7tEQk=
|
||||||
|
github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4=
|
||||||
|
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||||
|
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||||
|
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 h1:PVRE9d4AQKmbelZ7emNig1+NT27DUmKZn5qXxfio54U=
|
||||||
|
github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
|
||||||
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/jonas-p/go-shp v0.1.1/go.mod h1:MRIhyxDQ6VVp0oYeD7yPGr5RSTNScUFKCDsI5DR7PtI=
|
||||||
|
github.com/kisielk/errcheck v1.6.1 h1:cErYo+J4SmEjdXZrVXGwLJCE2sB06s23LpkcyWNrT+s=
|
||||||
|
github.com/kisielk/errcheck v1.6.1/go.mod h1:nXw/i/MfnvRHqXa7XXmQMUB0oNFGuBrNI8d8NLy0LPw=
|
||||||
|
github.com/kortschak/utter v0.0.0-20180609113506-364ec7d7a8f4/go.mod h1:oDr41C7kH9wvAikWyFhr6UFr8R7nelpmCF5XR5rL7I8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b h1:jdFI9paVi4E33U9TAExBpKPl1l5MnOn7VOLbb4Mvzzg=
|
||||||
|
github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b/go.mod h1:mOq/NVYz3H5h7Av88ia14HIMF/UdGXj9dp8P/+b566A=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
|
github.com/tealeg/xlsx v1.0.3/go.mod h1:uxu5UY2ovkuRPWKQ8Q7JG0JbSivrISjdPzZQKeo74mA=
|
||||||
|
github.com/tormoder/fit v0.15.0 h1:oW1dhvGqPIwBJdRJfWzW/jqYU705oBmLcJq4TJO7SqU=
|
||||||
|
github.com/tormoder/fit v0.15.0/go.mod h1:J+m0+sz5qljhPaP34CgJz8uFD8Vzdsf96D3Hj99DMLQ=
|
||||||
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE=
|
||||||
|
golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||||
|
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
|
||||||
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
|
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
|
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||||
|
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
|
||||||
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
|
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
|
||||||
|
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.4.1-0.20221208213631-3f74d914ae6d/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
||||||
|
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
|
||||||
|
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
honnef.co/go/tools v0.4.2 h1:6qXr+R5w+ktL5UkwEbPp+fEvfyoMPche6GkOpGHZcLc=
|
||||||
|
honnef.co/go/tools v0.4.2/go.mod h1:36ZgoUOrqOk1GxwHhyryEkq8FQWkUO2xGuSMhUCcdvA=
|
||||||
|
mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM=
|
||||||
|
mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ=
|
||||||
@@ -7,22 +7,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Activity struct {
|
type Activity struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
ActivityID int `json:"activity_id"`
|
ActivityID int `json:"activity_id"`
|
||||||
StartTime time.Time `json:"start_time"`
|
StartTime time.Time `json:"start_time"`
|
||||||
ActivityType string `json:"activity_type"`
|
ActivityType string `json:"activity_type"`
|
||||||
Duration int `json:"duration"` // seconds
|
Duration int `json:"duration"` // in seconds
|
||||||
Distance float64 `json:"distance"` // meters
|
Distance float64 `json:"distance"` // in meters
|
||||||
MaxHeartRate int `json:"max_heart_rate"`
|
MaxHeartRate int `json:"max_heart_rate"`
|
||||||
AvgHeartRate int `json:"avg_heart_rate"`
|
AvgHeartRate int `json:"avg_heart_rate"`
|
||||||
AvgPower float64 `json:"avg_power"`
|
AvgPower float64 `json:"avg_power"`
|
||||||
Calories int `json:"calories"`
|
Calories int `json:"calories"`
|
||||||
Filename string `json:"filename"`
|
Steps int `json:"steps"`
|
||||||
FileType string `json:"file_type"`
|
ElevationGain float64 `json:"elevation_gain"`
|
||||||
FileSize int64 `json:"file_size"`
|
StartLatitude float64 `json:"start_latitude"`
|
||||||
Downloaded bool `json:"downloaded"`
|
StartLongitude float64 `json:"start_longitude"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Filename string `json:"filename"`
|
||||||
LastSync time.Time `json:"last_sync"`
|
FileType string `json:"file_type"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Downloaded bool `json:"downloaded"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastSync time.Time `json:"last_sync"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
|
|||||||
@@ -30,24 +30,28 @@ func NewSQLiteDB(dbPath string) (*SQLiteDB, error) {
|
|||||||
|
|
||||||
func (s *SQLiteDB) createTables() error {
|
func (s *SQLiteDB) createTables() error {
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE IF NOT EXISTS activities (
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
activity_id INTEGER UNIQUE NOT NULL,
|
activity_id INTEGER UNIQUE NOT NULL,
|
||||||
start_time DATETIME NOT NULL,
|
start_time DATETIME NOT NULL,
|
||||||
activity_type TEXT,
|
activity_type TEXT,
|
||||||
duration INTEGER,
|
duration INTEGER,
|
||||||
distance REAL,
|
distance REAL,
|
||||||
max_heart_rate INTEGER,
|
max_heart_rate INTEGER,
|
||||||
avg_heart_rate INTEGER,
|
avg_heart_rate INTEGER,
|
||||||
avg_power REAL,
|
avg_power REAL,
|
||||||
calories INTEGER,
|
calories INTEGER,
|
||||||
filename TEXT UNIQUE,
|
steps INTEGER,
|
||||||
file_type TEXT,
|
elevation_gain REAL,
|
||||||
file_size INTEGER,
|
start_latitude REAL,
|
||||||
downloaded BOOLEAN DEFAULT FALSE,
|
start_longitude REAL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
filename TEXT UNIQUE,
|
||||||
last_sync DATETIME DEFAULT CURRENT_TIMESTAMP
|
file_type TEXT,
|
||||||
);
|
file_size INTEGER,
|
||||||
|
downloaded BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_sync DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id);
|
CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time);
|
CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time);
|
||||||
@@ -73,8 +77,9 @@ func (s *SQLiteDB) createTables() error {
|
|||||||
func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
|
func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
||||||
max_heart_rate, avg_heart_rate, avg_power, calories, filename,
|
max_heart_rate, avg_heart_rate, avg_power, calories, steps,
|
||||||
file_type, file_size, downloaded, created_at, last_sync
|
elevation_gain, start_latitude, start_longitude,
|
||||||
|
filename, file_type, file_size, downloaded, created_at, last_sync
|
||||||
FROM activities
|
FROM activities
|
||||||
ORDER BY start_time DESC
|
ORDER BY start_time DESC
|
||||||
LIMIT ? OFFSET ?`
|
LIMIT ? OFFSET ?`
|
||||||
@@ -93,8 +98,10 @@ func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
||||||
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
||||||
&a.AvgPower, &a.Calories, &a.Filename, &a.FileType,
|
&a.AvgPower, &a.Calories, &a.Steps, &a.ElevationGain,
|
||||||
&a.FileSize, &a.Downloaded, &createdAt, &lastSync,
|
&a.StartLatitude, &a.StartLongitude,
|
||||||
|
&a.Filename, &a.FileType, &a.FileSize, &a.Downloaded,
|
||||||
|
&createdAt, &lastSync,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -117,39 +124,89 @@ func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
|
|||||||
return activities, nil
|
return activities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) CreateActivity(activity *Activity) error {
|
func (s *SQLiteDB) GetActivity(activityID int) (*Activity, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO activities (
|
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
||||||
activity_id, start_time, activity_type, duration, distance,
|
max_heart_rate, avg_heart_rate, avg_power, calories, steps,
|
||||||
max_heart_rate, avg_heart_rate, avg_power, calories,
|
elevation_gain, start_latitude, start_longitude,
|
||||||
filename, file_type, file_size, downloaded
|
filename, file_type, file_size, downloaded, created_at, last_sync
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
FROM activities
|
||||||
|
WHERE activity_id = ?`
|
||||||
|
|
||||||
|
row := s.db.QueryRow(query, activityID)
|
||||||
|
|
||||||
|
var a Activity
|
||||||
|
var startTime, createdAt, lastSync string
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
||||||
|
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
||||||
|
&a.AvgPower, &a.Calories, &a.Steps, &a.ElevationGain,
|
||||||
|
&a.StartLatitude, &a.StartLongitude,
|
||||||
|
&a.Filename, &a.FileType, &a.FileSize, &a.Downloaded,
|
||||||
|
&createdAt, &lastSync,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("activity not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time strings
|
||||||
|
if a.StartTime, err = time.Parse("2006-01-02 15:04:05", startTime); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if a.LastSync, err = time.Parse("2006-01-02 15:04:05", lastSync); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteDB) CreateActivity(activity *Activity) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO activities (
|
||||||
|
activity_id, start_time, activity_type, duration, distance,
|
||||||
|
max_heart_rate, avg_heart_rate, avg_power, calories,
|
||||||
|
steps, elevation_gain, start_latitude, start_longitude,
|
||||||
|
filename, file_type, file_size, downloaded
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
_, err := s.db.Exec(query,
|
_, err := s.db.Exec(query,
|
||||||
activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"),
|
activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"),
|
||||||
activity.ActivityType, activity.Duration, activity.Distance,
|
activity.ActivityType, activity.Duration, activity.Distance,
|
||||||
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
|
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
|
||||||
activity.Calories, activity.Filename, activity.FileType,
|
activity.Calories, activity.Steps, activity.ElevationGain,
|
||||||
activity.FileSize, activity.Downloaded,
|
activity.StartLatitude, activity.StartLongitude,
|
||||||
|
activity.Filename, activity.FileType,
|
||||||
|
activity.FileSize, activity.Downloaded,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteDB) UpdateActivity(activity *Activity) error {
|
func (s *SQLiteDB) UpdateActivity(activity *Activity) error {
|
||||||
query := `
|
query := `
|
||||||
UPDATE activities SET
|
UPDATE activities SET
|
||||||
activity_type = ?, duration = ?, distance = ?,
|
activity_type = ?, duration = ?, distance = ?,
|
||||||
max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?,
|
max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?,
|
||||||
calories = ?, filename = ?, file_type = ?, file_size = ?,
|
calories = ?, steps = ?, elevation_gain = ?,
|
||||||
downloaded = ?, last_sync = CURRENT_TIMESTAMP
|
start_latitude = ?, start_longitude = ?,
|
||||||
WHERE activity_id = ?`
|
filename = ?, file_type = ?, file_size = ?,
|
||||||
|
downloaded = ?, last_sync = CURRENT_TIMESTAMP
|
||||||
|
WHERE activity_id = ?`
|
||||||
|
|
||||||
_, err := s.db.Exec(query,
|
_, err := s.db.Exec(query,
|
||||||
activity.ActivityType, activity.Duration, activity.Distance,
|
activity.ActivityType, activity.Duration, activity.Distance,
|
||||||
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
|
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
|
||||||
activity.Calories, activity.Filename, activity.FileType,
|
activity.Calories, activity.Steps, activity.ElevationGain,
|
||||||
activity.FileSize, activity.Downloaded, activity.ActivityID,
|
activity.StartLatitude, activity.StartLongitude,
|
||||||
|
activity.Filename, activity.FileType,
|
||||||
|
activity.FileSize, activity.Downloaded, activity.ActivityID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -177,9 +234,10 @@ func (s *SQLiteDB) GetStats() (*Stats, error) {
|
|||||||
|
|
||||||
func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) {
|
func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
||||||
max_heart_rate, avg_heart_rate, avg_power, calories, filename,
|
max_heart_rate, avg_heart_rate, avg_power, calories, steps,
|
||||||
file_type, file_size, downloaded, created_at, last_sync
|
elevation_gain, start_latitude, start_longitude,
|
||||||
|
filename, file_type, file_size, downloaded, created_at, last_sync
|
||||||
FROM activities WHERE 1=1`
|
FROM activities WHERE 1=1`
|
||||||
|
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
@@ -257,10 +315,12 @@ func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error)
|
|||||||
var startTime, createdAt, lastSync string
|
var startTime, createdAt, lastSync string
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
|
||||||
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
|
||||||
&a.AvgPower, &a.Calories, &a.Filename, &a.FileType,
|
&a.AvgPower, &a.Calories, &a.Steps, &a.ElevationGain,
|
||||||
&a.FileSize, &a.Downloaded, &createdAt, &lastSync,
|
&a.StartLatitude, &a.StartLongitude,
|
||||||
|
&a.Filename, &a.FileType, &a.FileSize, &a.Downloaded,
|
||||||
|
&createdAt, &lastSync,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -280,3 +340,8 @@ func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error)
|
|||||||
func (s *SQLiteDB) Close() error {
|
func (s *SQLiteDB) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSQLiteDBFromDB wraps an existing sql.DB connection
|
||||||
|
func NewSQLiteDBFromDB(db *sql.DB) *SQLiteDB {
|
||||||
|
return &SQLiteDB{db: db}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ type GarminActivity struct {
|
|||||||
AvgHR int `json:"avgHR"`
|
AvgHR int `json:"avgHR"`
|
||||||
AvgPower float64 `json:"avgPower"`
|
AvgPower float64 `json:"avgPower"`
|
||||||
Calories int `json:"calories"`
|
Calories int `json:"calories"`
|
||||||
|
StartLatitude float64 `json:"startLatitude"`
|
||||||
|
StartLongitude float64 `json:"startLongitude"`
|
||||||
|
Steps int `json:"steps"`
|
||||||
|
ElevationGain float64 `json:"elevationGain"`
|
||||||
|
ElevationLoss float64 `json:"elevationLoss"`
|
||||||
|
AvgTemperature float64 `json:"avgTemperature"`
|
||||||
|
MinTemperature float64 `json:"minTemperature"`
|
||||||
|
MaxTemperature float64 `json:"maxTemperature"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient() *Client {
|
func NewClient() *Client {
|
||||||
@@ -246,6 +254,14 @@ func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
|
|||||||
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract activity type from map if possible
|
||||||
|
if typeKey, ok := activity.ActivityType["typeKey"].(string); ok {
|
||||||
|
activity.ActivityType = map[string]interface{}{"typeKey": typeKey}
|
||||||
|
} else {
|
||||||
|
// Default to empty map if typeKey not found
|
||||||
|
activity.ActivityType = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
|
|||||||
@@ -1,323 +1,35 @@
|
|||||||
// internal/parser/activity.go
|
|
||||||
package parser
|
package parser
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"encoding/xml"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// ActivityMetrics contains all metrics extracted from activity files
|
||||||
type ActivityMetrics struct {
|
type ActivityMetrics struct {
|
||||||
ActivityType string
|
ActivityType string
|
||||||
Duration int // seconds
|
StartTime time.Time
|
||||||
Distance float64 // meters
|
Duration time.Duration
|
||||||
MaxHR int
|
Distance float64 // in meters
|
||||||
AvgHR int
|
MaxHeartRate int
|
||||||
AvgPower float64
|
AvgHeartRate int
|
||||||
Calories int
|
AvgPower int
|
||||||
StartTime time.Time
|
Calories int
|
||||||
|
Steps int
|
||||||
|
ElevationGain float64 // in meters
|
||||||
|
ElevationLoss float64 // in meters
|
||||||
|
MinTemperature float64 // in °C
|
||||||
|
MaxTemperature float64 // in °C
|
||||||
|
AvgTemperature float64 // in °C
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parser defines the interface for activity file parsers
|
||||||
type Parser interface {
|
type Parser interface {
|
||||||
ParseFile(filepath string) (*ActivityMetrics, error)
|
ParseFile(filename string) (*ActivityMetrics, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParser(fileType FileType) Parser {
|
// FileType represents supported file formats
|
||||||
switch fileType {
|
type FileType string
|
||||||
case FileTypeFIT:
|
|
||||||
return &FITParser{}
|
|
||||||
case FileTypeTCX:
|
|
||||||
return &TCXParser{}
|
|
||||||
case FileTypeGPX:
|
|
||||||
return &GPXParser{}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TCX Parser Implementation
|
const (
|
||||||
type TCXParser struct{}
|
FIT FileType = "fit"
|
||||||
|
TCX FileType = "tcx"
|
||||||
type TCXTrainingCenterDatabase struct {
|
GPX FileType = "gpx"
|
||||||
Activities TCXActivities `xml:"Activities"`
|
)
|
||||||
}
|
|
||||||
|
|
||||||
type TCXActivities struct {
|
|
||||||
Activity []TCXActivity `xml:"Activity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCXActivity struct {
|
|
||||||
Sport string `xml:"Sport,attr"`
|
|
||||||
Laps []TCXLap `xml:"Lap"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCXLap struct {
|
|
||||||
StartTime string `xml:"StartTime,attr"`
|
|
||||||
TotalTimeSeconds float64 `xml:"TotalTimeSeconds"`
|
|
||||||
DistanceMeters float64 `xml:"DistanceMeters"`
|
|
||||||
Calories int `xml:"Calories"`
|
|
||||||
MaximumSpeed float64 `xml:"MaximumSpeed"`
|
|
||||||
AverageHeartRate TCXHeartRate `xml:"AverageHeartRateBpm"`
|
|
||||||
MaximumHeartRate TCXHeartRate `xml:"MaximumHeartRateBpm"`
|
|
||||||
Track TCXTrack `xml:"Track"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCXHeartRate struct {
|
|
||||||
Value int `xml:"Value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCXTrack struct {
|
|
||||||
Trackpoints []TCXTrackpoint `xml:"Trackpoint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCXTrackpoint struct {
|
|
||||||
Time string `xml:"Time"`
|
|
||||||
HeartRateBpm TCXHeartRate `xml:"HeartRateBpm"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *TCXParser) ParseFile(filepath string) (*ActivityMetrics, error) {
|
|
||||||
file, err := os.Open(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var tcx TCXTrainingCenterDatabase
|
|
||||||
decoder := xml.NewDecoder(file)
|
|
||||||
if err := decoder.Decode(&tcx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tcx.Activities.Activity) == 0 || len(tcx.Activities.Activity[0].Laps) == 0 {
|
|
||||||
return nil, fmt.Errorf("no activity data found")
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := tcx.Activities.Activity[0]
|
|
||||||
firstLap := activity.Laps[0]
|
|
||||||
|
|
||||||
metrics := &ActivityMetrics{
|
|
||||||
ActivityType: mapTCXSportType(activity.Sport),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse start time
|
|
||||||
if startTime, err := time.Parse(time.RFC3339, firstLap.StartTime); err == nil {
|
|
||||||
metrics.StartTime = startTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate data from all laps
|
|
||||||
var totalDuration, totalDistance float64
|
|
||||||
var maxHR, totalCalories int
|
|
||||||
var hrValues []int
|
|
||||||
|
|
||||||
for _, lap := range activity.Laps {
|
|
||||||
totalDuration += lap.TotalTimeSeconds
|
|
||||||
totalDistance += lap.DistanceMeters
|
|
||||||
totalCalories += lap.Calories
|
|
||||||
|
|
||||||
if lap.MaximumHeartRate.Value > maxHR {
|
|
||||||
maxHR = lap.MaximumHeartRate.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
if lap.AverageHeartRate.Value > 0 {
|
|
||||||
hrValues = append(hrValues, lap.AverageHeartRate.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect HR data from trackpoints
|
|
||||||
for _, tp := range lap.Track.Trackpoints {
|
|
||||||
if tp.HeartRateBpm.Value > 0 {
|
|
||||||
hrValues = append(hrValues, tp.HeartRateBpm.Value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics.Duration = int(totalDuration)
|
|
||||||
metrics.Distance = totalDistance
|
|
||||||
metrics.MaxHR = maxHR
|
|
||||||
metrics.Calories = totalCalories
|
|
||||||
|
|
||||||
// Calculate average HR
|
|
||||||
if len(hrValues) > 0 {
|
|
||||||
sum := 0
|
|
||||||
for _, hr := range hrValues {
|
|
||||||
sum += hr
|
|
||||||
}
|
|
||||||
metrics.AvgHR = sum / len(hrValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
return metrics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapTCXSportType(sport string) string {
|
|
||||||
switch sport {
|
|
||||||
case "Running":
|
|
||||||
return "running"
|
|
||||||
case "Biking":
|
|
||||||
return "cycling"
|
|
||||||
case "Swimming":
|
|
||||||
return "swimming"
|
|
||||||
default:
|
|
||||||
return "other"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPX Parser Implementation
|
|
||||||
type GPXParser struct{}
|
|
||||||
|
|
||||||
type GPX struct {
|
|
||||||
Tracks []GPXTrack `xml:"trk"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GPXTrack struct {
|
|
||||||
Name string `xml:"name"`
|
|
||||||
Segments []GPXSegment `xml:"trkseg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GPXSegment struct {
|
|
||||||
Points []GPXPoint `xml:"trkpt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GPXPoint struct {
|
|
||||||
Lat float64 `xml:"lat,attr"`
|
|
||||||
Lon float64 `xml:"lon,attr"`
|
|
||||||
Elevation float64 `xml:"ele"`
|
|
||||||
Time string `xml:"time"`
|
|
||||||
HR int `xml:"extensions>TrackPointExtension>hr"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GPXParser) ParseFile(filepath string) (*ActivityMetrics, error) {
|
|
||||||
file, err := os.Open(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var gpx GPX
|
|
||||||
decoder := xml.NewDecoder(file)
|
|
||||||
if err := decoder.Decode(&gpx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(gpx.Tracks) == 0 || len(gpx.Tracks[0].Segments) == 0 {
|
|
||||||
return nil, fmt.Errorf("no track data found")
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := &ActivityMetrics{
|
|
||||||
ActivityType: "other", // GPX doesn't specify activity type
|
|
||||||
}
|
|
||||||
|
|
||||||
var allPoints []GPXPoint
|
|
||||||
for _, track := range gpx.Tracks {
|
|
||||||
for _, segment := range track.Segments {
|
|
||||||
allPoints = append(allPoints, segment.Points...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(allPoints) == 0 {
|
|
||||||
return nil, fmt.Errorf("no track points found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate metrics from points
|
|
||||||
var startTime, endTime time.Time
|
|
||||||
var totalDistance float64
|
|
||||||
var hrValues []int
|
|
||||||
|
|
||||||
for i, point := range allPoints {
|
|
||||||
// Parse time
|
|
||||||
if point.Time != "" {
|
|
||||||
if t, err := time.Parse(time.RFC3339, point.Time); err == nil {
|
|
||||||
if i == 0 {
|
|
||||||
startTime = t
|
|
||||||
metrics.StartTime = t
|
|
||||||
}
|
|
||||||
endTime = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate distance between consecutive points
|
|
||||||
if i > 0 {
|
|
||||||
prevPoint := allPoints[i-1]
|
|
||||||
distance := calculateDistance(prevPoint.Lat, prevPoint.Lon, point.Lat, point.Lon)
|
|
||||||
totalDistance += distance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect heart rate data
|
|
||||||
if point.HR > 0 {
|
|
||||||
hrValues = append(hrValues, point.HR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate duration
|
|
||||||
if !startTime.IsZero() && !endTime.IsZero() {
|
|
||||||
metrics.Duration = int(endTime.Sub(startTime).Seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics.Distance = totalDistance
|
|
||||||
|
|
||||||
// Calculate heart rate metrics
|
|
||||||
if len(hrValues) > 0 {
|
|
||||||
sum := 0
|
|
||||||
maxHR := 0
|
|
||||||
for _, hr := range hrValues {
|
|
||||||
sum += hr
|
|
||||||
if hr > maxHR {
|
|
||||||
maxHR = hr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metrics.AvgHR = sum / len(hrValues)
|
|
||||||
metrics.MaxHR = maxHR
|
|
||||||
}
|
|
||||||
|
|
||||||
return metrics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Haversine formula for distance calculation
|
|
||||||
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
|
|
||||||
const earthRadius = 6371000 // Earth's radius in meters
|
|
||||||
|
|
||||||
dLat := (lat2 - lat1) * math.Pi / 180
|
|
||||||
dLon := (lon2 - lon1) * math.Pi / 180
|
|
||||||
|
|
||||||
lat1Rad := lat1 * math.Pi / 180
|
|
||||||
lat2Rad := lat2 * math.Pi / 180
|
|
||||||
|
|
||||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
||||||
math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
||||||
|
|
||||||
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
||||||
|
|
||||||
return earthRadius * c
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIT Parser Implementation (simplified - would use FIT SDK in real implementation)
|
|
||||||
type FITParser struct{}
|
|
||||||
|
|
||||||
func (p *FITParser) ParseFile(filepath string) (*ActivityMetrics, error) {
|
|
||||||
// For now, return basic metrics - in real implementation, would use FIT SDK
|
|
||||||
// This is a placeholder that reads basic file info
|
|
||||||
|
|
||||||
file, err := os.Open(filepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Read FIT header to verify it's a valid FIT file
|
|
||||||
header := make([]byte, 14)
|
|
||||||
_, err = file.Read(header)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify FIT signature
|
|
||||||
if !bytes.Equal(header[8:12], []byte(".FIT")) {
|
|
||||||
return nil, fmt.Errorf("invalid FIT file signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, return empty metrics - real implementation would parse FIT records
|
|
||||||
return &ActivityMetrics{
|
|
||||||
ActivityType: "other",
|
|
||||||
// Additional parsing would happen here using FIT SDK
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,55 +1,31 @@
|
|||||||
// internal/parser/detector.go
|
|
||||||
package parser
|
package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileType string
|
var (
|
||||||
|
// FIT file signature
|
||||||
const (
|
fitSignature = []byte{0x0E, 0x10} // .FIT files start with 0x0E 0x10
|
||||||
FileTypeFIT FileType = "fit"
|
|
||||||
FileTypeTCX FileType = "tcx"
|
|
||||||
FileTypeGPX FileType = "gpx"
|
|
||||||
FileTypeUnknown FileType = "unknown"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func DetectFileType(filepath string) (FileType, error) {
|
// DetectFileType detects the file type based on its content
|
||||||
file, err := os.Open(filepath)
|
func DetectFileType(data []byte) (string, error) {
|
||||||
if err != nil {
|
// Check FIT file signature
|
||||||
return FileTypeUnknown, err
|
if len(data) >= 2 && bytes.Equal(data[:2], fitSignature) {
|
||||||
}
|
return ".fit", nil
|
||||||
defer file.Close()
|
}
|
||||||
|
|
||||||
// Read first 512 bytes for detection
|
|
||||||
header := make([]byte, 512)
|
|
||||||
n, err := file.Read(header)
|
|
||||||
if err != nil && n == 0 {
|
|
||||||
return FileTypeUnknown, err
|
|
||||||
}
|
|
||||||
|
|
||||||
header = header[:n]
|
|
||||||
|
|
||||||
return DetectFileTypeFromData(header), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DetectFileTypeFromData(data []byte) FileType {
|
// Check TCX file signature (XML with TrainingCenterDatabase root)
|
||||||
// Check for FIT file signature
|
if bytes.Contains(data, []byte("<TrainingCenterDatabase")) {
|
||||||
if len(data) >= 8 && bytes.Equal(data[8:12], []byte(".FIT")) {
|
return ".tcx", nil
|
||||||
return FileTypeFIT
|
}
|
||||||
}
|
|
||||||
|
// Check GPX file signature (XML with <gpx> root)
|
||||||
// Check for XML-based formats
|
if bytes.Contains(data, []byte("<gpx")) {
|
||||||
if bytes.HasPrefix(data, []byte("<?xml")) {
|
return ".gpx", nil
|
||||||
if bytes.Contains(data[:200], []byte("<gpx")) ||
|
}
|
||||||
bytes.Contains(data[:200], []byte("topografix.com/GPX")) {
|
|
||||||
return FileTypeGPX
|
return "", errors.New("unrecognized file format")
|
||||||
}
|
|
||||||
if bytes.Contains(data[:500], []byte("TrainingCenterDatabase")) {
|
|
||||||
return FileTypeTCX
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileTypeUnknown
|
|
||||||
}
|
}
|
||||||
|
|||||||
57
internal/parser/factory.go
Normal file
57
internal/parser/factory.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewParser creates a parser based on file extension or content
|
||||||
|
func NewParser(filename string) (Parser, error) {
|
||||||
|
// First try by extension
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
switch ext {
|
||||||
|
case ".fit":
|
||||||
|
return NewFITParser(), nil
|
||||||
|
case ".tcx":
|
||||||
|
return NewTCXParser(), nil // To be implemented
|
||||||
|
case ".gpx":
|
||||||
|
return NewGPXParser(), nil // To be implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
// If extension doesn't match, detect by content
|
||||||
|
fileType, err := DetectFileTypeFromFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to detect file type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fileType {
|
||||||
|
case FIT:
|
||||||
|
return NewFITParser(), nil
|
||||||
|
case TCX:
|
||||||
|
return NewTCXParser(), nil
|
||||||
|
case GPX:
|
||||||
|
return NewGPXParser(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported file type: %s", fileType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParserFromData creates a parser based on file content
|
||||||
|
func NewParserFromData(data []byte) (Parser, error) {
|
||||||
|
fileType := DetectFileTypeFromData(data)
|
||||||
|
|
||||||
|
switch fileType {
|
||||||
|
case FIT:
|
||||||
|
return NewFITParser(), nil
|
||||||
|
case TCX:
|
||||||
|
return NewTCXParser(), nil
|
||||||
|
case GPX:
|
||||||
|
return NewGPXParser(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported file type: %s", fileType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder implementations (will create these next)
|
||||||
|
func NewTCXParser() Parser { return nil }
|
||||||
|
func NewGPXParser() Parser { return nil }
|
||||||
91
internal/parser/fit_parser.go
Normal file
91
internal/parser/fit_parser.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tormoder/fit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FITParser struct{}
|
||||||
|
|
||||||
|
func NewFITParser() *FITParser {
|
||||||
|
return &FITParser{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FITParser) ParseFile(filename string) (*ActivityMetrics, error) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.ParseData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FITParser) ParseData(data []byte) (*ActivityMetrics, error) {
|
||||||
|
fitFile, err := fit.Decode(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode FIT file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, err := fitFile.Activity()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get activity from FIT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(activity.Sessions) == 0 {
|
||||||
|
return nil, fmt.Errorf("no sessions found in FIT file")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := activity.Sessions[0]
|
||||||
|
metrics := &ActivityMetrics{}
|
||||||
|
|
||||||
|
// Basic activity metrics
|
||||||
|
metrics.StartTime = session.StartTime
|
||||||
|
metrics.Duration = time.Duration(session.TotalTimerTime) * time.Second
|
||||||
|
metrics.Distance = session.TotalDistance
|
||||||
|
|
||||||
|
// Heart rate
|
||||||
|
if session.AvgHeartRate != nil {
|
||||||
|
metrics.AvgHeartRate = int(*session.AvgHeartRate)
|
||||||
|
}
|
||||||
|
if session.MaxHeartRate != nil {
|
||||||
|
metrics.MaxHeartRate = int(*session.MaxHeartRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power
|
||||||
|
if session.AvgPower != nil {
|
||||||
|
metrics.AvgPower = int(*session.AvgPower)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calories
|
||||||
|
if session.TotalCalories != nil {
|
||||||
|
metrics.Calories = int(*session.TotalCalories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elevation
|
||||||
|
if session.TotalAscent != nil {
|
||||||
|
metrics.ElevationGain = *session.TotalAscent
|
||||||
|
}
|
||||||
|
if session.TotalDescent != nil {
|
||||||
|
metrics.ElevationLoss = *session.TotalDescent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
if session.Steps != nil {
|
||||||
|
metrics.Steps = int(*session.Steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temperature - FIT typically doesn't store temp in session summary
|
||||||
|
// We'll leave temperature fields as 0 for FIT files
|
||||||
|
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
100
internal/parser/gpx_parser.go
Normal file
100
internal/parser/gpx_parser.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourusername/garminsync/internal/parser/activity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPX represents the root element of a GPX file
|
||||||
|
type GPX struct {
|
||||||
|
XMLName xml.Name `xml:"gpx"`
|
||||||
|
Trk Trk `xml:"trk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trk represents a track in a GPX file
|
||||||
|
type Trk struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
TrkSeg []TrkSeg `xml:"trkseg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrkSeg represents a track segment in a GPX file
|
||||||
|
type TrkSeg struct {
|
||||||
|
TrkPt []TrkPt `xml:"trkpt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrkPt represents a track point in a GPX file
|
||||||
|
type TrkPt struct {
|
||||||
|
Lat float64 `xml:"lat,attr"`
|
||||||
|
Lon float64 `xml:"lon,attr"`
|
||||||
|
Ele float64 `xml:"ele"`
|
||||||
|
Time string `xml:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPXParser implements the Parser interface for GPX files
|
||||||
|
type GPXParser struct{}
|
||||||
|
|
||||||
|
func (p *GPXParser) Parse(data []byte) (*activity.Activity, error) {
|
||||||
|
var gpx GPX
|
||||||
|
if err := xml.Unmarshal(data, &gpx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gpx.Trk.TrkSeg) == 0 || len(gpx.Trk.TrkSeg[0].TrkPt) == 0 {
|
||||||
|
return nil, ErrNoTrackData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process track points
|
||||||
|
points := gpx.Trk.TrkSeg[0].TrkPt
|
||||||
|
startTime, _ := time.Parse(time.RFC3339, points[0].Time)
|
||||||
|
endTime, _ := time.Parse(time.RFC3339, points[len(points)-1].Time)
|
||||||
|
|
||||||
|
activity := &activity.Activity{
|
||||||
|
ActivityType: "hiking",
|
||||||
|
StartTime: startTime,
|
||||||
|
Duration: int(endTime.Sub(startTime).Seconds()),
|
||||||
|
StartLatitude: points[0].Lat,
|
||||||
|
StartLongitude: points[0].Lon,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate distance and elevation
|
||||||
|
var totalDistance, elevationGain float64
|
||||||
|
prev := points[0]
|
||||||
|
|
||||||
|
for i := 1; i < len(points); i++ {
|
||||||
|
curr := points[i]
|
||||||
|
totalDistance += haversine(prev.Lat, prev.Lon, curr.Lat, curr.Lon)
|
||||||
|
|
||||||
|
if curr.Ele > prev.Ele {
|
||||||
|
elevationGain += curr.Ele - prev.Ele
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
|
||||||
|
activity.Distance = totalDistance
|
||||||
|
activity.ElevationGain = elevationGain
|
||||||
|
|
||||||
|
return activity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// haversine calculates the distance between two points on Earth
|
||||||
|
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
|
const R = 6371000 // Earth radius in meters
|
||||||
|
φ1 := lat1 * math.Pi / 180
|
||||||
|
φ2 := lat2 * math.Pi / 180
|
||||||
|
Δφ := (lat2 - lat1) * math.Pi / 180
|
||||||
|
Δλ := (lon2 - lon1) * math.Pi / 180
|
||||||
|
|
||||||
|
a := math.Sin(Δφ/2)*math.Sin(Δφ/2) +
|
||||||
|
math.Cos(φ1)*math.Cos(φ2)*
|
||||||
|
math.Sin(Δλ/2)*math.Sin(Δλ/2)
|
||||||
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
|
||||||
|
return R * c
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterParser(".gpx", &GPXParser{})
|
||||||
|
}
|
||||||
145
internal/sync/sync.go
Normal file
145
internal/sync/sync.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yourusername/garminsync/internal/database"
|
||||||
|
"github.com/yourusername/garminsync/internal/garmin"
|
||||||
|
"github.com/yourusername/garminsync/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncService struct {
|
||||||
|
garminClient *garmin.Client
|
||||||
|
db *database.SQLiteDB
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncService(garminClient *garmin.Client, db *database.SQLiteDB, dataDir string) *SyncService {
|
||||||
|
return &SyncService{
|
||||||
|
garminClient: garminClient,
|
||||||
|
db: db,
|
||||||
|
dataDir: dataDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SyncService) Sync(ctx context.Context) error {
|
||||||
|
startTime := time.Now()
|
||||||
|
fmt.Printf("Starting sync at %s\n", startTime.Format(time.RFC3339))
|
||||||
|
defer func() {
|
||||||
|
fmt.Printf("Sync completed in %s\n", time.Since(startTime))
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 1. Fetch latest activities from Garmin
|
||||||
|
activities, err := s.garminClient.GetActivities(0, 100)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get activities: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Found %d activities on Garmin\n", len(activities))
|
||||||
|
|
||||||
|
// 2. Sync each activity
|
||||||
|
for i, activity := range activities {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
fmt.Printf("[%d/%d] Processing activity %d...\n", i+1, len(activities), activity.ActivityID)
|
||||||
|
if err := s.syncActivity(&activity); err != nil {
|
||||||
|
fmt.Printf("Error syncing activity %d: %v\n", activity.ActivityID, err)
|
||||||
|
// Continue with next activity on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SyncService) syncActivity(activity *garmin.GarminActivity) error {
|
||||||
|
// Check if activity exists in database
|
||||||
|
dbActivity, err := s.db.GetActivity(activity.ActivityID)
|
||||||
|
if err == nil {
|
||||||
|
// Activity exists - check if already downloaded
|
||||||
|
if dbActivity.Downloaded {
|
||||||
|
fmt.Printf("Activity %d already downloaded\n", activity.ActivityID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Activity not in database - create new record
|
||||||
|
dbActivity = &database.Activity{
|
||||||
|
ActivityID: activity.ActivityID,
|
||||||
|
StartTime: parseTime(activity.StartTimeLocal),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add basic info if available
|
||||||
|
if activityType, ok := activity.ActivityType["typeKey"]; ok {
|
||||||
|
dbActivity.ActivityType = activityType.(string)
|
||||||
|
}
|
||||||
|
dbActivity.Duration = int(activity.Duration)
|
||||||
|
dbActivity.Distance = activity.Distance
|
||||||
|
|
||||||
|
if err := s.db.CreateActivity(dbActivity); err != nil {
|
||||||
|
return fmt.Errorf("failed to create activity: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the activity file (FIT format)
|
||||||
|
fileData, err := s.garminClient.DownloadActivity(activity.ActivityID, "fit")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine filename
|
||||||
|
filename := filepath.Join(
|
||||||
|
s.dataDir,
|
||||||
|
"activities",
|
||||||
|
fmt.Sprintf("%d_%s.fit", activity.ActivityID, activity.StartTimeLocal[:10]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create directories if needed
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
if err := os.WriteFile(filename, fileData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file to extract additional metrics
|
||||||
|
metrics, err := parser.NewFITParser().ParseData(fileData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse activity file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update activity with parsed metrics
|
||||||
|
dbActivity.Duration = int(metrics.Duration.Seconds())
|
||||||
|
dbActivity.Distance = metrics.Distance
|
||||||
|
dbActivity.MaxHeartRate = metrics.MaxHeartRate
|
||||||
|
dbActivity.AvgHeartRate = metrics.AvgHeartRate
|
||||||
|
dbActivity.AvgPower = metrics.AvgPower
|
||||||
|
dbActivity.Calories = metrics.Calories
|
||||||
|
dbActivity.Downloaded = true
|
||||||
|
dbActivity.Filename = filename
|
||||||
|
dbActivity.FileType = "fit"
|
||||||
|
|
||||||
|
// Save updated activity
|
||||||
|
if err := s.db.UpdateActivity(dbActivity); err != nil {
|
||||||
|
return fmt.Errorf("failed to update activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully synced activity %d\n", activity.ActivityID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTime(timeStr string) time.Time {
|
||||||
|
// Garmin time format: "2023-08-15 12:30:45"
|
||||||
|
t, err := time.Parse("2006-01-02 15:04:05", timeStr)
|
||||||
|
if err != nil {
|
||||||
|
return time.Now()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
@@ -1,239 +1,103 @@
|
|||||||
// internal/web/routes.go
|
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"net/http"
|
||||||
"fmt"
|
"html/template"
|
||||||
"net/http"
|
"path/filepath"
|
||||||
"strconv"
|
"os"
|
||||||
"time"
|
|
||||||
|
"github.com/yourusername/garminsync/internal/database"
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"garminsync/internal/database"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type WebHandler struct {
|
||||||
db database.Database
|
db *database.SQLiteDB
|
||||||
router *mux.Router
|
templates map[string]*template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(db database.Database) *Server {
|
func NewWebHandler(db *database.SQLiteDB) *WebHandler {
|
||||||
s := &Server{
|
return &WebHandler{
|
||||||
db: db,
|
db: db,
|
||||||
router: mux.NewRouter(),
|
templates: make(map[string]*template.Template),
|
||||||
}
|
}
|
||||||
|
|
||||||
s.setupRoutes()
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setupRoutes() {
|
func (h *WebHandler) LoadTemplates(templateDir string) error {
|
||||||
// Static files (embedded)
|
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||||
s.router.HandleFunc("/", s.handleHome).Methods("GET")
|
if err != nil {
|
||||||
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
|
return err
|
||||||
|
}
|
||||||
// API routes
|
|
||||||
api := s.router.PathPrefix("/api").Subrouter()
|
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
|
||||||
|
if err != nil {
|
||||||
// Activities
|
return err
|
||||||
api.HandleFunc("/activities", s.handleGetActivities).Methods("GET")
|
}
|
||||||
api.HandleFunc("/activities/{id:[0-9]+}", s.handleGetActivity).Methods("GET")
|
|
||||||
api.HandleFunc("/activities/search", s.handleSearchActivities).Methods("GET")
|
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||||
|
if err != nil {
|
||||||
// Stats
|
return err
|
||||||
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
|
}
|
||||||
api.HandleFunc("/stats/summary", s.handleGetStatsSummary).Methods("GET")
|
|
||||||
|
for _, page := range pages {
|
||||||
// Sync operations
|
name := filepath.Base(page)
|
||||||
api.HandleFunc("/sync", s.handleTriggerSync).Methods("POST")
|
|
||||||
api.HandleFunc("/sync/status", s.handleGetSyncStatus).Methods("GET")
|
files := append([]string{page}, layouts...)
|
||||||
|
files = append(files, partials...)
|
||||||
// Configuration
|
|
||||||
api.HandleFunc("/config", s.handleGetConfig).Methods("GET")
|
h.templates[name], err = template.ParseFiles(files...)
|
||||||
api.HandleFunc("/config", s.handleUpdateConfig).Methods("POST")
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||||
s.router.ServeHTTP(w, r)
|
stats, err := h.db.GetStats()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, "index.html", stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) ActivityList(w http.ResponseWriter, r *http.Request) {
|
||||||
// Serve embedded HTML
|
activities, err := h.db.GetActivities(50, 0)
|
||||||
html := getEmbeddedHTML()
|
if err != nil {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
w.Write([]byte(html))
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, "activity_list.html", activities)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) ActivityDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
s.writeJSON(w, map[string]string{
|
// Extract activity ID from URL params
|
||||||
"status": "healthy",
|
activityID, err := strconv.Atoi(r.URL.Query().Get("id"))
|
||||||
"service": "GarminSync",
|
if err != nil {
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
http.Error(w, "Invalid activity ID", http.StatusBadRequest)
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, err := h.db.GetActivity(activityID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Activity not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, "activity_detail.html", activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetActivities(w http.ResponseWriter, r *http.Request) {
|
func (h *WebHandler) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||||
// Parse query parameters
|
tmpl, ok := h.templates[name]
|
||||||
query := r.URL.Query()
|
if !ok {
|
||||||
|
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
return
|
||||||
if limit <= 0 || limit > 100 {
|
}
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build filters
|
|
||||||
filters := database.ActivityFilters{
|
|
||||||
Limit: limit,
|
|
||||||
Offset: offset,
|
|
||||||
}
|
|
||||||
|
|
||||||
if activityType := query.Get("activity_type"); activityType != "" {
|
|
||||||
filters.ActivityType = activityType
|
|
||||||
}
|
|
||||||
|
|
||||||
if dateFrom := query.Get("date_from"); dateFrom != "" {
|
|
||||||
if t, err := time.Parse("2006-01-02", dateFrom); err == nil {
|
|
||||||
filters.DateFrom = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if dateTo := query.Get("date_to"); dateTo != "" {
|
|
||||||
if t, err := time.Parse("2006-01-02", dateTo); err == nil {
|
|
||||||
filters.DateTo = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if minDistance := query.Get("min_distance"); minDistance != "" {
|
|
||||||
if d, err := strconv.ParseFloat(minDistance, 64); err == nil {
|
|
||||||
filters.MinDistance = d * 1000 // Convert km to meters
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sortBy := query.Get("sort_by"); sortBy != "" {
|
|
||||||
filters.SortBy = sortBy
|
|
||||||
}
|
|
||||||
|
|
||||||
if sortOrder := query.Get("sort_order"); sortOrder != "" {
|
|
||||||
filters.SortOrder = sortOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get activities
|
|
||||||
activities, err := s.db.FilterActivities(filters)
|
|
||||||
if err != nil {
|
|
||||||
s.writeError(w, "Failed to get activities", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to API response format
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"activities": convertActivitiesToAPI(activities),
|
|
||||||
"limit": limit,
|
|
||||||
"offset": offset,
|
|
||||||
}
|
|
||||||
|
|
||||||
s.writeJSON(w, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
vars := mux.Vars(r)
|
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||||
activityID, err := strconv.Atoi(vars["id"])
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
if err != nil {
|
}
|
||||||
s.writeError(w, "Invalid activity ID", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activity, err := s.db.GetActivity(activityID)
|
|
||||||
if err != nil {
|
|
||||||
s.writeError(w, "Activity not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.writeJSON(w, convertActivityToAPI(*activity))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stats, err := s.db.GetStats()
|
|
||||||
if err != nil {
|
|
||||||
s.writeError(w, "Failed to get statistics", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.writeJSON(w, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleTriggerSync(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// This would trigger the sync operation
|
|
||||||
// For now, return success
|
|
||||||
s.writeJSON(w, map[string]string{
|
|
||||||
"status": "sync_started",
|
|
||||||
"message": "Sync operation started in background",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
func (s *Server) writeJSON(w http.ResponseWriter, data interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) writeError(w http.ResponseWriter, message string, status int) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"error": message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertActivitiesToAPI(activities []database.Activity) []map[string]interface{} {
|
|
||||||
result := make([]map[string]interface{}, len(activities))
|
|
||||||
for i, activity := range activities {
|
|
||||||
result[i] = convertActivityToAPI(activity)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertActivityToAPI(activity database.Activity) map[string]interface{} {
|
|
||||||
return map[string]interface{}{
|
|
||||||
"id": activity.ID,
|
|
||||||
"activity_id": activity.ActivityID,
|
|
||||||
"start_time": activity.StartTime.Format("2006-01-02T15:04:05Z"),
|
|
||||||
"activity_type": activity.ActivityType,
|
|
||||||
"duration": activity.Duration,
|
|
||||||
"duration_formatted": formatDuration(activity.Duration),
|
|
||||||
"distance": activity.Distance,
|
|
||||||
"distance_km": roundFloat(activity.Distance/1000, 2),
|
|
||||||
"max_heart_rate": activity.MaxHeartRate,
|
|
||||||
"avg_heart_rate": activity.AvgHeartRate,
|
|
||||||
"avg_power": activity.AvgPower,
|
|
||||||
"calories": activity.Calories,
|
|
||||||
"file_type": activity.FileType,
|
|
||||||
"downloaded": activity.Downloaded,
|
|
||||||
"created_at": activity.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
||||||
"last_sync": activity.LastSync.Format("2006-01-02T15:04:05Z"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDuration(seconds int) string {
|
|
||||||
if seconds <= 0 {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
hours := seconds / 3600
|
|
||||||
minutes := (seconds % 3600) / 60
|
|
||||||
secs := seconds % 60
|
|
||||||
|
|
||||||
if hours > 0 {
|
|
||||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d:%02d", minutes, secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func roundFloat(val float64, precision int) float64 {
|
|
||||||
ratio := math.Pow(10, float64(precision))
|
|
||||||
return math.Round(val*ratio) / ratio
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
internal/web/templates/layouts/base.html
Normal file
47
internal/web/templates/layouts/base.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>GarminSync - {{block "title" .}}{{end}}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #1c6bff;
|
||||||
|
--primary-hover: #0a5af7;
|
||||||
|
}
|
||||||
|
.activity-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.activity-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="container-fluid">
|
||||||
|
<ul>
|
||||||
|
<li><strong>GarminSync</strong></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/activities">Activities</a></li>
|
||||||
|
<li><a href="/settings">Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<main class="container">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</main>
|
||||||
|
<footer class="container-fluid">
|
||||||
|
<p>GarminSync v1.0 · Last sync: {{.LastSync}}</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
internal/web/templates/pages/activity_detail.html
Normal file
70
internal/web/templates/pages/activity_detail.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{{define "title"}}{{.ActivityName}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<hgroup>
|
||||||
|
<h1>{{.ActivityName}}</h1>
|
||||||
|
<h2>{{.ActivityType}} • {{.StartTime.Format "Jan 2, 2006"}}</h2>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<h3>Metrics</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Distance:</strong> {{printf "%.2f km" (div .Distance 1000)}}</li>
|
||||||
|
<li><strong>Duration:</strong> {{.Duration | formatDuration}}</li>
|
||||||
|
<li><strong>Avg HR:</strong> {{.AvgHeartRate}} bpm</li>
|
||||||
|
<li><strong>Avg Power:</strong> {{.AvgPower}}W</li>
|
||||||
|
<li><strong>Calories:</strong> {{.Calories}}</li>
|
||||||
|
<li><strong>Steps:</strong> {{.Steps}}</li>
|
||||||
|
<li><strong>Elevation Gain:</strong> {{.ElevationGain | formatMeters}}m</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Location</h3>
|
||||||
|
{{if and (ne .StartLatitude 0) (ne .StartLongitude 0)}}
|
||||||
|
<div id="map" style="height: 300px; background: #f0f0f0; border-radius: 4px;">
|
||||||
|
<!-- Map will be rendered here -->
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Simple static map for now - will be enhanced later
|
||||||
|
const mapEl = document.getElementById('map');
|
||||||
|
const lat = {{.StartLatitude}};
|
||||||
|
const lng = {{.StartLongitude}};
|
||||||
|
mapEl.innerHTML = `
|
||||||
|
<iframe
|
||||||
|
width="100%"
|
||||||
|
height="300"
|
||||||
|
frameborder="0"
|
||||||
|
style="border:0"
|
||||||
|
src="https://www.openstreetmap.org/export/embed.html?bbox=${lng-0.01},${lat-0.01},${lng+0.01},${lat+0.01}&layer=mapnik&marker=${lat},${lng}">
|
||||||
|
</iframe>
|
||||||
|
`;
|
||||||
|
</script>
|
||||||
|
{{else}}
|
||||||
|
<p>No location data available</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="{{.Filename}}" download role="button">Download FIT File</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
// Custom formatting function for duration
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const hrs = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hrs}h ${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register as helper function
|
||||||
|
window.formatDuration = formatDuration;
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
27
internal/web/templates/pages/activity_list.html
Normal file
27
internal/web/templates/pages/activity_list.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "title"}}Activities{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h1>Activity List</h1>
|
||||||
|
<div class="grid">
|
||||||
|
<form method="get">
|
||||||
|
<input type="search" name="q" placeholder="Search activities..."
|
||||||
|
hx-get="/activities" hx-trigger="keyup changed delay:500ms"
|
||||||
|
hx-target="#activity-list" hx-include="this">
|
||||||
|
</form>
|
||||||
|
<select name="type" hx-get="/activities" hx-trigger="change"
|
||||||
|
hx-target="#activity-list" hx-include="previous input">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="running">Running</option>
|
||||||
|
<option value="cycling">Cycling</option>
|
||||||
|
<option value="swimming">Swimming</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="activity-list" hx-get="/partials/activities" hx-trigger="load">
|
||||||
|
Loading activities...
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
35
internal/web/templates/pages/index.html
Normal file
35
internal/web/templates/pages/index.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "title"}}Dashboard{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<article>
|
||||||
|
<h1>Activity Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="card">
|
||||||
|
<header>Total Activities</header>
|
||||||
|
<h2>{{.Total}}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<header>Downloaded</header>
|
||||||
|
<h2>{{.Downloaded}}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<header>Missing</header>
|
||||||
|
<h2>{{.Missing}}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<a href="/sync" role="button" class="secondary">Sync Now</a>
|
||||||
|
<a href="/activities" role="button">View Activities</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h2>Recent Activities</h2>
|
||||||
|
{{/* Will be replaced by HTMX */}}
|
||||||
|
<div id="recent-activities" hx-get="/partials/recent_activities" hx-trigger="load">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
283
main.go
283
main.go
@@ -2,131 +2,198 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"log"
|
||||||
"os"
|
"net/http"
|
||||||
"os/signal"
|
"os"
|
||||||
"syscall"
|
"os/signal"
|
||||||
"time"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
"github.com/joho/godotenv"
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
|
"github.com/yourusername/garminsync/internal/database"
|
||||||
|
"github.com/yourusername/garminsync/internal/garmin"
|
||||||
|
"github.com/yourusername/garminsync/internal/sync"
|
||||||
|
"github.com/yourusername/garminsync/internal/web"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
db *sql.DB
|
db *database.SQLiteDB
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
server *http.Server
|
server *http.Server
|
||||||
garmin *GarminClient
|
garmin *garmin.Client
|
||||||
shutdown chan os.Signal
|
shutdown chan os.Signal
|
||||||
|
syncService *sync.SyncService
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &App{
|
// Load environment variables from .env file
|
||||||
shutdown: make(chan os.Signal, 1),
|
if err := godotenv.Load(); err != nil {
|
||||||
}
|
log.Println("No .env file found, using system environment variables")
|
||||||
|
}
|
||||||
// Initialize components
|
|
||||||
if err := app.init(); err != nil {
|
app := &App{
|
||||||
log.Fatal("Failed to initialize app:", err)
|
shutdown: make(chan os.Signal, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start services
|
// Initialize components
|
||||||
app.start()
|
if err := app.init(); err != nil {
|
||||||
|
log.Fatal("Failed to initialize app:", err)
|
||||||
// Wait for shutdown signal
|
}
|
||||||
signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM)
|
|
||||||
<-app.shutdown
|
// Start services
|
||||||
|
app.start()
|
||||||
// Graceful shutdown
|
|
||||||
app.stop()
|
// Wait for shutdown signal
|
||||||
|
signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-app.shutdown
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
app.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) init() error {
|
func (app *App) init() error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
app.db, err = initDatabase()
|
dbConn, err := initDatabase()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
app.db = database.NewSQLiteDBFromDB(dbConn)
|
||||||
// Initialize Garmin client
|
|
||||||
app.garmin = NewGarminClient()
|
// Initialize Garmin client
|
||||||
|
app.garmin = garmin.NewClient()
|
||||||
// Setup cron scheduler
|
|
||||||
app.cron = cron.New()
|
// Initialize sync service
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
// Setup HTTP server
|
if dataDir == "" {
|
||||||
app.server = &http.Server{
|
dataDir = "./data"
|
||||||
Addr: ":8888",
|
}
|
||||||
Handler: app.setupRoutes(),
|
app.syncService = sync.NewSyncService(app.garmin, database.NewSQLiteDBFromDB(app.db), dataDir)
|
||||||
}
|
|
||||||
|
// Setup cron scheduler
|
||||||
return nil
|
app.cron = cron.New()
|
||||||
|
|
||||||
|
// Setup HTTP server
|
||||||
|
webHandler := web.NewWebHandler(app.db)
|
||||||
|
templateDir := os.Getenv("TEMPLATE_DIR")
|
||||||
|
if templateDir == "" {
|
||||||
|
templateDir = "./internal/web/templates"
|
||||||
|
}
|
||||||
|
if err := webHandler.LoadTemplates(templateDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to load templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.server = &http.Server{
|
||||||
|
Addr: ":8888",
|
||||||
|
Handler: app.setupRoutes(webHandler),
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) start() {
|
func (app *App) start() {
|
||||||
// Start cron scheduler
|
// Start cron scheduler
|
||||||
app.cron.AddFunc("@hourly", func() {
|
app.cron.AddFunc("@hourly", func() {
|
||||||
log.Println("Starting scheduled sync...")
|
log.Println("Starting scheduled sync...")
|
||||||
app.syncActivities()
|
if err := app.syncService.Sync(context.Background()); err != nil {
|
||||||
})
|
log.Printf("Sync failed: %v", err)
|
||||||
app.cron.Start()
|
}
|
||||||
|
})
|
||||||
// Start web server
|
app.cron.Start()
|
||||||
go func() {
|
|
||||||
log.Println("Server starting on http://localhost:8888")
|
// Start web server
|
||||||
if err := app.server.ListenAndServe(); err != http.ErrServerClosed {
|
go func() {
|
||||||
log.Printf("Server error: %v", err)
|
log.Println("Server starting on http://localhost:8888")
|
||||||
}
|
if err := app.server.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
}()
|
log.Printf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) stop() {
|
func (app *App) stop() {
|
||||||
log.Println("Shutting down...")
|
log.Println("Shutting down...")
|
||||||
|
|
||||||
// Stop cron
|
// Stop cron
|
||||||
app.cron.Stop()
|
app.cron.Stop()
|
||||||
|
|
||||||
// Stop web server
|
// Stop web server
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := app.server.Shutdown(ctx); err != nil {
|
if err := app.server.Shutdown(ctx); err != nil {
|
||||||
log.Printf("Server shutdown error: %v", err)
|
log.Printf("Server shutdown error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close database
|
// Close database
|
||||||
if app.db != nil {
|
if app.db != nil {
|
||||||
app.db.Close()
|
app.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Shutdown complete")
|
log.Println("Shutdown complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
// main.go - Database initialization
|
// Database initialization
|
||||||
func initDatabase() (*sql.DB, error) {
|
func initDatabase() (*sql.DB, error) {
|
||||||
// Get data directory from environment or use default
|
// Get database path from environment or use default
|
||||||
dataDir := os.Getenv("DATA_DIR")
|
dbPath := os.Getenv("DB_PATH")
|
||||||
if dataDir == "" {
|
if dbPath == "" {
|
||||||
dataDir = "./data"
|
// Fallback to DATA_DIR/garmin.db if DB_PATH not set
|
||||||
}
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
// Create data directory if it doesn't exist
|
dataDir = "./data"
|
||||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
}
|
||||||
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
|
||||||
}
|
// Create data directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
dbPath := filepath.Join(dataDir, "garmin.db")
|
return nil, fmt.Errorf("failed to create data directory: %v", err)
|
||||||
|
}
|
||||||
// Initialize SQLite database
|
|
||||||
db, err := database.NewSQLiteDB(dbPath)
|
dbPath = filepath.Join(dataDir, "garmin.db")
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("failed to initialize database: %v", err)
|
|
||||||
}
|
// Initialize SQLite database
|
||||||
|
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
|
||||||
return db.db, nil // Return the underlying *sql.DB
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("database ping failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tables if they don't exist
|
||||||
|
sqliteDB := &database.SQLiteDB{db: db}
|
||||||
|
if err := sqliteDB.createTables(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create tables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application routes
|
||||||
|
func (app *App) setupRoutes(webHandler *web.WebHandler) *http.ServeMux {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Web UI routes
|
||||||
|
mux.HandleFunc("/", webHandler.Index)
|
||||||
|
mux.HandleFunc("/activities", webHandler.ActivityList)
|
||||||
|
mux.HandleFunc("/activity", webHandler.ActivityDetail)
|
||||||
|
|
||||||
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user