Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions clients/cli/cmd/internal/download_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package internal

import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"

"github.com/antihax/optional"
"github.com/phrase/phrase-go/v4"
)

const (
cacheVersion = 1
cacheFileName = "download_cache.json"
cacheDirName = "phrase"
)

type CacheEntry struct {
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
}

type DownloadCache struct {
Version int `json:"version"`
Entries map[string]CacheEntry `json:"entries"`
path string
dirty bool
}

func LoadDownloadCache() *DownloadCache {
return loadFromPath(cachePath())
}

func loadFromPath(path string) *DownloadCache {
dc := &DownloadCache{
Version: cacheVersion,
Entries: make(map[string]CacheEntry),
path: path,
}

data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Warning: could not read download cache %s: %v\n", path, err)
}
return dc
}

var loaded DownloadCache
if err := json.Unmarshal(data, &loaded); err != nil {
fmt.Fprintf(os.Stderr, "Warning: corrupt download cache %s, starting fresh\n", path)
return dc
}
if loaded.Version != cacheVersion {
return dc
}
loaded.path = path
if loaded.Entries == nil {
loaded.Entries = make(map[string]CacheEntry)
}
return &loaded
}

func (dc *DownloadCache) Get(key string) (CacheEntry, bool) {
e, ok := dc.Entries[key]
return e, ok
}

func (dc *DownloadCache) Set(key string, entry CacheEntry) {
dc.Entries[key] = entry
dc.dirty = true
}

func (dc *DownloadCache) Save() error {
if !dc.dirty {
return nil
}
if err := os.MkdirAll(filepath.Dir(dc.path), 0o700); err != nil {
return err
}
data, err := json.Marshal(dc)
if err != nil {
return err
}
if err := os.WriteFile(dc.path, data, 0o600); err != nil {
return err
}
dc.dirty = false
return nil
}

// CacheKey builds a deterministic key by hashing the full download parameters.
// It uses reflection to extract actual values from optional fields since
// antihax/optional types don't serialize meaningfully via json.Marshal.
func CacheKey(projectID, localeID string, opts phrase.LocaleDownloadOpts) string {
// Zero out conditional request fields so they don't affect the key.
opts.IfNoneMatch = optional.String{}
opts.IfModifiedSince = optional.String{}

raw := fmt.Sprintf("%s/%s/%s", projectID, localeID, serializeOpts(opts))
h := sha256.Sum256([]byte(raw))
return fmt.Sprintf("%x", h[:12])
}

// serializeOpts extracts set values from optional fields into a deterministic map.
// It assumes all fields in LocaleDownloadOpts are either slices or antihax/optional
// types with IsSet()/Value() methods. Fields with other types are silently excluded.
func serializeOpts(opts phrase.LocaleDownloadOpts) string {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could probably avoid having this function by reusing the logic from https://github.com/phrase/phrase-go/blob/master/api_locales.go#L407 which is essentially also generating a string from the download options.

right now that logic is embedded into the request function itself, but should be possible to extract it into a separate function and use that here as well (unfortunately, it would need to be done in the template file here https://github.com/phrase/strings-openapi/blob/main/openapi-generator/templates/go/api.mustache

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes good catch. It can be done into a follow-up PR indeed. Doing it here would increase the radius.

v := reflect.ValueOf(opts)
t := v.Type()
m := make(map[string]interface{})

for i := 0; i < t.NumField(); i++ {
field := v.Field(i)
name := t.Field(i).Name

// Handle slices directly
if field.Kind() == reflect.Slice {
if field.Len() > 0 {
m[name] = field.Interface()
}
continue
}

// For optional types, check IsSet and extract Value
isSetMethod := field.MethodByName("IsSet")
valueMethod := field.MethodByName("Value")
if isSetMethod.IsValid() && valueMethod.IsValid() {
results := isSetMethod.Call(nil)
if len(results) > 0 && results[0].Bool() {
m[name] = valueMethod.Call(nil)[0].Interface()
}
}
}

data, err := json.Marshal(m)
if err != nil {
return fmt.Sprintf("%v", m)
}
return string(data)
}

func cachePath() string {
dir, err := os.UserCacheDir()
if err != nil {
dir = os.TempDir()
}
return filepath.Join(dir, cacheDirName, cacheFileName)
}
Loading
Loading