Files
aicyclingcoach-go/internal/storage/activities.go
2025-09-17 17:30:18 -07:00

177 lines
4.5 KiB
Go

package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/sstent/fitness-tui/internal/tui/models"
)
type ActivityStorage struct {
dataDir string
lockPath string
}
func NewActivityStorage(dataDir string) *ActivityStorage {
activitiesDir := filepath.Join(dataDir, "activities")
os.MkdirAll(activitiesDir, 0755)
// Create directory for activity files
filesDir := filepath.Join(dataDir, "activity_files")
os.MkdirAll(filesDir, 0755)
return &ActivityStorage{
dataDir: dataDir,
lockPath: filepath.Join(dataDir, "sync.lock"),
}
}
// AcquireLock tries to create an exclusive lock file
func (s *ActivityStorage) AcquireLock() error {
file, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("sync already in progress")
}
return fmt.Errorf("failed to acquire lock: %w", err)
}
file.Close()
return nil
}
// ReleaseLock removes the lock file
func (s *ActivityStorage) ReleaseLock() error {
if err := os.Remove(s.lockPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to release lock: %w", err)
}
return nil
}
func (s *ActivityStorage) Save(activity *models.Activity) error {
filename := fmt.Sprintf("%s-%s.json",
activity.Date.Format("2006-01-02"),
sanitizeFilename(activity.Name))
targetPath := filepath.Join(s.dataDir, "activities", filename)
data, err := json.MarshalIndent(activity, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal activity: %w", err)
}
// Atomic write using temp file and rename
tmpFile, err := os.CreateTemp(filepath.Dir(targetPath), "tmp-*.json")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(data); err != nil {
return fmt.Errorf("failed to write activity data: %w", err)
}
// Sync to ensure write completes before rename
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := os.Rename(tmpFile.Name(), targetPath); err != nil {
return fmt.Errorf("failed to atomically replace activity file: %w", err)
}
return nil
}
// SaveActivityFile saves the activity file (GPX/TCX) and returns the relative path to the file
func (s *ActivityStorage) SaveActivityFile(activity *models.Activity, content []byte, format string) (string, error) {
filename := fmt.Sprintf("%s.%s", activity.ID, format)
targetPath := filepath.Join(s.dataDir, "activity_files", filename)
if err := os.WriteFile(targetPath, content, 0644); err != nil {
return "", fmt.Errorf("failed to write activity file: %w", err)
}
// Return the relative path within the dataDir
relativePath := filepath.Join("activity_files", filename)
return relativePath, nil
}
func (s *ActivityStorage) LoadAll() ([]*models.Activity, error) {
activitiesDir := filepath.Join(s.dataDir, "activities")
files, err := os.ReadDir(activitiesDir)
if err != nil {
return nil, err
}
var activities []*models.Activity
for _, file := range files {
if filepath.Ext(file.Name()) != ".json" {
continue
}
activity, err := s.loadActivity(filepath.Join(activitiesDir, file.Name()))
if err != nil {
continue // Skip invalid files
}
activities = append(activities, activity)
}
sort.Slice(activities, func(i, j int) bool {
return activities[i].Date.After(activities[j].Date)
})
return activities, nil
}
func (s *ActivityStorage) Get(id string) (*models.Activity, error) {
activitiesDir := filepath.Join(s.dataDir, "activities")
files, err := os.ReadDir(activitiesDir)
if err != nil {
return nil, fmt.Errorf("failed to read activities directory: %w", err)
}
for _, file := range files {
if filepath.Ext(file.Name()) != ".json" {
continue
}
// Check if filename contains the activity ID
if strings.Contains(file.Name(), id) {
return s.loadActivity(filepath.Join(activitiesDir, file.Name()))
}
}
return nil, fmt.Errorf("activity %s not found", id)
}
func (s *ActivityStorage) loadActivity(path string) (*models.Activity, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var activity models.Activity
if err := json.Unmarshal(data, &activity); err != nil {
return nil, err
}
return &activity, nil
}
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"/", "-", "\\", "-", ":", "-", "*", "-",
"?", "-", "\"", "-", "<", "-", ">", "-",
"|", "-", " ", "-",
)
return replacer.Replace(name)
}