// Package cache stores the lookups that never change: a place name's // coordinates, and a coordinate's TERYT powiat code. // // Forecasts are never cached -- they would be stale immediately. package cache import ( "bytes" "encoding/json" "fmt" "os" "path/filepath" "sort" ) // Geo is a resolved location. type Geo struct { Lat float64 Lon float64 Label string Country string } // Geo is stored as a 4-element array, not an object, because the Python // implementation shares this file and reads [lat, lon, label, country]. The two // coexist until the Go version reaches parity, and a cache one of them cannot // read makes the other crash on its own data. func (g Geo) MarshalJSON() ([]byte, error) { return g.jsonLine() } // jsonLine renders the stored array with a space after each comma, which is how // it is written to disk. encoding/json compacts whatever a Marshaler returns, so // the file writer calls this directly; going through json.Marshal would strip // the spaces again. Element order is the contract described above, kept here so // it is stated once. func (g Geo) jsonLine() ([]byte, error) { parts := make([][]byte, 0, 4) for _, v := range []any{g.Lat, g.Lon, g.Label, g.Country} { b, err := json.Marshal(v) if err != nil { return nil, err } parts = append(parts, b) } return append(append([]byte{'['}, bytes.Join(parts, []byte(", "))...), ']'), nil } func (g *Geo) UnmarshalJSON(b []byte) error { var raw []any if err := json.Unmarshal(b, &raw); err != nil { return err } if len(raw) < 3 { return fmt.Errorf("geo entry has %d fields, want at least 3", len(raw)) } lat, ok1 := raw[0].(float64) lon, ok2 := raw[1].(float64) if !ok1 || !ok2 { return fmt.Errorf("geo entry has non-numeric coordinates") } label, _ := raw[2].(string) country := "" if len(raw) > 3 { country, _ = raw[3].(string) } *g = Geo{Lat: lat, Lon: lon, Label: label, Country: country} return nil } type store struct { Geo map[string]Geo `json:"geo"` Teryt map[string]string `json:"teryt"` } // Cache is a JSON file holding both sections. type Cache struct{ path string } // New returns a cache backed by path. The file need not exist. func New(path string) *Cache { return &Cache{path: path} } // DefaultPath is ~/.cache/prognosis/cache.json. func DefaultPath() string { if dir, err := os.UserCacheDir(); err == nil { return filepath.Join(dir, "prognosis", "cache.json") } return filepath.Join(os.Getenv("HOME"), ".cache", "prognosis", "cache.json") } // load never fails the run: a cache it cannot read is treated as empty, because // losing a cache costs one extra request and failing the run costs the forecast. // // It salvages per entry rather than per file. Decoding the sections whole meant // one unreadable entry made the entire cache look empty, and the save that // followed then wrote that emptiness over every entry that was still good. // // The second result reports whether the file was usable. It is false only when // the document itself will not parse, which tells the writers to move it aside // before replacing it: the cache is disposable, but a file someone hand-edited // is the only copy of what they typed. func (c *Cache) load() (store, bool) { s := store{Geo: map[string]Geo{}, Teryt: map[string]string{}} data, err := os.ReadFile(c.path) if err != nil { return s, true // absent is not corrupt; a fresh cache may be written } if len(bytes.TrimSpace(data)) == 0 { return s, true // an empty file is simply no cache yet } var raw struct { Geo map[string]json.RawMessage `json:"geo"` Teryt map[string]json.RawMessage `json:"teryt"` } if err := json.Unmarshal(data, &raw); err != nil { return s, false } for k, v := range raw.Geo { var g Geo if err := json.Unmarshal(v, &g); err == nil { s.Geo[k] = g } } for k, v := range raw.Teryt { var code string if err := json.Unmarshal(v, &code); err == nil { s.Teryt[k] = code } } return s, true } // encode writes the store one entry per line, keys sorted so the file is stable // between runs. It is small, hand-edited, and read in a terminal, none of which // a single long line serves. func encode(s store) ([]byte, error) { geo := make(map[string]json.RawMessage, len(s.Geo)) for k, g := range s.Geo { v, err := g.jsonLine() if err != nil { return nil, err } geo[k] = v } teryt := make(map[string]json.RawMessage, len(s.Teryt)) for k, code := range s.Teryt { v, err := json.Marshal(code) if err != nil { return nil, err } teryt[k] = v } var b bytes.Buffer b.WriteString("{\n") writeSection(&b, "geo", geo) b.WriteString(",\n") writeSection(&b, "teryt", teryt) b.WriteString("\n}\n") return b.Bytes(), nil } func writeSection(b *bytes.Buffer, name string, entries map[string]json.RawMessage) { if len(entries) == 0 { fmt.Fprintf(b, " %q: {}", name) return } keys := make([]string, 0, len(entries)) for k := range entries { keys = append(keys, k) } sort.Strings(keys) fmt.Fprintf(b, " %q: {\n", name) for i, k := range keys { key, err := json.Marshal(k) // a place name may contain anything if err != nil { continue } fmt.Fprintf(b, " %s: %s", key, entries[k]) if i < len(keys)-1 { b.WriteByte(',') } b.WriteByte('\n') } b.WriteString(" }") } // save writes atomically: a temporary file in the same directory, then a // rename. Two runs at once would otherwise interleave and leave truncated JSON // that the next run silently reads as an empty cache. func (c *Cache) save(s store) error { if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { return err } tmp := fmt.Sprintf("%s.%d.tmp", c.path, os.Getpid()) data, err := encode(s) if err != nil { return err } if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } if err := os.Rename(tmp, c.path); err != nil { os.Remove(tmp) return err } return nil } // quarantine moves a cache that will not parse to .bad, so replacing it // costs nothing that cannot be recovered. Losing the cache is cheap -- one extra // request -- but losing a hand-edit is not, and the two used to be the same act. func (c *Cache) quarantine() error { return os.Rename(c.path, c.path+".bad") } // Geo returns a cached location. func (c *Cache) Geo(place string) (Geo, bool) { s, _ := c.load() g, ok := s.Geo[place] return g, ok } // PutGeo records a location. func (c *Cache) PutGeo(place string, g Geo) error { s, ok := c.load() if !ok { if err := c.quarantine(); err != nil { return err } } s.Geo[place] = g return c.save(s) } // Teryt returns a cached powiat code. The empty string is a real answer meaning // "GUGiK knows this point is not in Poland"; the boolean distinguishes it from // never having asked. func (c *Cache) Teryt(key string) (string, bool) { s, _ := c.load() code, ok := s.Teryt[key] return code, ok } // PutTeryt records a powiat code, or "" for a point outside Poland. func (c *Cache) PutTeryt(key, code string) error { s, ok := c.load() if !ok { if err := c.quarantine(); err != nil { return err } } s.Teryt[key] = code return c.save(s) }