This commit is contained in:
2025-09-17 17:30:18 -07:00
parent 6bad6cae00
commit 84ba6432c2
65 changed files with 434 additions and 765 deletions

View File

@@ -0,0 +1,176 @@
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)
}

View File

@@ -0,0 +1,100 @@
package storage
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sstent/fitness-tui/internal/tui/models"
"gopkg.in/yaml.v3"
)
const (
analysisDir = "analysis"
)
type AnalysisCache struct {
storagePath string
}
func NewAnalysisCache(storagePath string) *AnalysisCache {
return &AnalysisCache{
storagePath: filepath.Join(storagePath, analysisDir),
}
}
type AnalysisMetadata struct {
ActivityID string `yaml:"activity_id"`
GeneratedAt time.Time `yaml:"generated_at"`
ModelUsed string `yaml:"model_used"`
Hash string `yaml:"hash"`
}
func (c *AnalysisCache) StoreAnalysis(activity *models.Activity, content string, meta AnalysisMetadata) error {
basePath := filepath.Join(c.storagePath, activity.ID)
if err := os.MkdirAll(basePath, 0755); err != nil {
return fmt.Errorf("failed to create analysis dir: %w", err)
}
// Encode metadata as YAML
metaYAML, err := yaml.Marshal(&meta)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
// Create hybrid document with YAML front matter
var hybridContent bytes.Buffer
hybridContent.WriteString("---\n")
hybridContent.Write(metaYAML)
hybridContent.WriteString("---\n\n")
hybridContent.WriteString(content)
// Write hybrid document
contentPath := filepath.Join(basePath, "analysis.md")
if err := os.WriteFile(contentPath, hybridContent.Bytes(), 0644); err != nil {
return fmt.Errorf("failed to write analysis: %w", err)
}
return nil
}
func (c *AnalysisCache) GetAnalysis(activityID string) (string, *AnalysisMetadata, error) {
basePath := filepath.Join(c.storagePath, activityID)
contentPath := filepath.Join(basePath, "analysis.md")
data, err := os.ReadFile(contentPath)
if err != nil {
return "", nil, fmt.Errorf("failed to read analysis: %w", err)
}
// Split YAML front matter from content
parts := strings.SplitN(string(data), "---", 3)
if len(parts) < 3 {
return "", nil, fmt.Errorf("invalid analysis format")
}
// Parse YAML metadata
var meta AnalysisMetadata
if err := yaml.Unmarshal([]byte(parts[1]), &meta); err != nil {
return "", nil, fmt.Errorf("failed to parse metadata: %w", err)
}
// The rest is markdown content
content := strings.TrimSpace(parts[2])
return content, &meta, nil
}
func (c *AnalysisCache) HasFreshAnalysis(activityID string, ttl time.Duration) bool {
basePath := filepath.Join(c.storagePath, activityID)
contentPath := filepath.Join(basePath, "analysis.md")
info, err := os.Stat(contentPath)
if err != nil {
return false
}
return time.Since(info.ModTime()) < ttl
}