package main import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "time" ) const ( locationCacheFile = "location.json" forecastCacheFile = "forecast.json" afdCacheFile = "afd.json" ) type Cache struct { dir string } type LocationCache struct { Query string `json:"query"` Location Location `json:"location"` Recent []Location `json:"recent"` 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"` } type AFDCache struct { OfficeID string `json:"office_id"` ProductID string `json:"product_id"` Text string `json:"text"` IssuanceTime string `json:"issuance_time"` CheckedAt time.Time `json:"checked_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{} if err := readJSON(filepath.Join(c.dir, locationCacheFile), &payload); err != nil { if !errors.Is(err, os.ErrNotExist) { return err } } payload.Query = query payload.Location = loc payload.UpdatedAt = time.Now().UTC() payload.Recent = addRecentLocation(payload.Recent, loc, 10) 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) LoadRecentLocations() ([]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 nil, nil } return nil, err } return payload.Recent, 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() payload.Recent = addRecentLocation(payload.Recent, loc, 10) 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) LoadAFD() (AFDCache, bool, error) { path := filepath.Join(c.dir, afdCacheFile) var payload AFDCache if err := readJSON(path, &payload); err != nil { if errors.Is(err, os.ErrNotExist) { return AFDCache{}, false, nil } return AFDCache{}, false, err } return payload, true, nil } func (c *Cache) SaveAFD(afd AFDCache) error { if err := c.ensureDir(); err != nil { return err } return writeJSON(filepath.Join(c.dir, afdCacheFile), afd) } 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 } if err := os.Remove(filepath.Join(c.dir, afdCacheFile)); 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 addRecentLocation(recent []Location, loc Location, limit int) []Location { if limit <= 0 { return recent } trimmed := make([]Location, 0, len(recent)+1) trimmed = append(trimmed, loc) for _, item := range recent { if sameLocation(item, loc) { continue } trimmed = append(trimmed, item) if len(trimmed) >= limit { break } } return trimmed } 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 }