package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "time" ) const ( locationCacheFile = "location.json" forecastCacheFile = "forecast.json" ) type Cache struct { dir string } type LocationCache struct { Query string `json:"query"` Location Location `json:"location"` UpdatedAt time.Time `json:"updated_at"` } type ForecastCache struct { Location Location `json:"location"` Unit string `json:"unit"` Period ForecastPeriod `json:"period"` FetchedAt time.Time `json:"fetched_at"` } func NewCache(dir string) *Cache { return &Cache{dir: dir} } func (c *Cache) SaveLocation(query string, loc Location) error { if err := c.ensureDir(); err != nil { return err } payload := LocationCache{ Query: query, Location: loc, UpdatedAt: time.Now().UTC(), } if err := writeJSON(filepath.Join(c.dir, locationCacheFile), payload); err != nil { return err } return c.ClearForecastAndRadar() } func (c *Cache) LoadLocation() (Location, bool, error) { path := filepath.Join(c.dir, locationCacheFile) var payload LocationCache if err := readJSON(path, &payload); err != nil { if errors.Is(err, os.ErrNotExist) { return Location{}, false, nil } return Location{}, false, err } return payload.Location, true, nil } func (c *Cache) UpdateLocation(loc Location) error { path := filepath.Join(c.dir, locationCacheFile) var payload LocationCache if err := readJSON(path, &payload); err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("no cached location found to update") } return err } payload.Location = loc payload.UpdatedAt = time.Now().UTC() return writeJSON(path, payload) } func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) error { if err := c.ensureDir(); err != nil { return err } payload := ForecastCache{ Location: loc, Unit: unit, Period: period, FetchedAt: time.Now().UTC(), } return writeJSON(filepath.Join(c.dir, forecastCacheFile), payload) } func (c *Cache) LoadForecast(loc Location, unit string, ttl time.Duration) (ForecastPeriod, bool, error) { path := filepath.Join(c.dir, forecastCacheFile) var payload ForecastCache if err := readJSON(path, &payload); err != nil { if errors.Is(err, os.ErrNotExist) { return ForecastPeriod{}, false, nil } return ForecastPeriod{}, false, err } if payload.Unit != unit { return ForecastPeriod{}, false, nil } if !sameLocation(payload.Location, loc) { return ForecastPeriod{}, false, nil } if time.Since(payload.FetchedAt) > ttl { return ForecastPeriod{}, false, nil } return payload.Period, true, nil } func (c *Cache) ensureDir() error { if c.dir == "" { return fmt.Errorf("cache directory is empty") } return os.MkdirAll(c.dir, 0o755) } func (c *Cache) Clear() error { if c.dir == "" { return fmt.Errorf("cache directory is empty") } if err := os.Remove(filepath.Join(c.dir, locationCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return c.ClearForecastAndRadar() } func (c *Cache) ClearForecastAndRadar() error { if c.dir == "" { return fmt.Errorf("cache directory is empty") } if err := os.Remove(filepath.Join(c.dir, forecastCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } if err := os.Remove(filepath.Join(c.dir, "radar.gif")); err != nil && !errors.Is(err, os.ErrNotExist) { return err } return nil } func readJSON(path string, target any) error { data, err := os.ReadFile(path) if err != nil { return err } return json.Unmarshal(data, target) } func writeJSON(path string, value any) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0o644) } func sameLocation(a, b Location) bool { return a.Lat == b.Lat && a.Lon == b.Lon } func cacheRoot() (string, error) { if override := os.Getenv("WEATHER_CACHE_DIR"); override != "" { return override, nil } if base := os.Getenv("XDG_CACHE_HOME"); base != "" { return filepath.Join(base, "weather"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".cache", "weather"), nil }