mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-04-03 19:43:23 +00:00
sync
This commit is contained in:
176
internal/storage/activities.go
Normal file
176
internal/storage/activities.go
Normal 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)
|
||||
}
|
||||
100
internal/storage/analysis.go
Normal file
100
internal/storage/analysis.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user