Skip to content

Commit b815b79

Browse files
authored
feat(CLI): add --cache flag to phrase pull for conditional requests (#1066)
1 parent 4f6cb6d commit b815b79

5 files changed

Lines changed: 481 additions & 11 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package internal
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"reflect"
10+
11+
"github.com/antihax/optional"
12+
"github.com/phrase/phrase-go/v4"
13+
)
14+
15+
const (
16+
cacheVersion = 1
17+
cacheFileName = "download_cache.json"
18+
cacheDirName = "phrase"
19+
)
20+
21+
type CacheEntry struct {
22+
ETag string `json:"etag,omitempty"`
23+
LastModified string `json:"last_modified,omitempty"`
24+
}
25+
26+
type DownloadCache struct {
27+
Version int `json:"version"`
28+
Entries map[string]CacheEntry `json:"entries"`
29+
path string
30+
dirty bool
31+
}
32+
33+
func LoadDownloadCache() *DownloadCache {
34+
return loadFromPath(cachePath())
35+
}
36+
37+
func loadFromPath(path string) *DownloadCache {
38+
dc := &DownloadCache{
39+
Version: cacheVersion,
40+
Entries: make(map[string]CacheEntry),
41+
path: path,
42+
}
43+
44+
data, err := os.ReadFile(path)
45+
if err != nil {
46+
if !os.IsNotExist(err) {
47+
fmt.Fprintf(os.Stderr, "Warning: could not read download cache %s: %v\n", path, err)
48+
}
49+
return dc
50+
}
51+
52+
var loaded DownloadCache
53+
if err := json.Unmarshal(data, &loaded); err != nil {
54+
fmt.Fprintf(os.Stderr, "Warning: corrupt download cache %s, starting fresh\n", path)
55+
return dc
56+
}
57+
if loaded.Version != cacheVersion {
58+
return dc
59+
}
60+
loaded.path = path
61+
if loaded.Entries == nil {
62+
loaded.Entries = make(map[string]CacheEntry)
63+
}
64+
return &loaded
65+
}
66+
67+
func (dc *DownloadCache) Get(key string) (CacheEntry, bool) {
68+
e, ok := dc.Entries[key]
69+
return e, ok
70+
}
71+
72+
func (dc *DownloadCache) Set(key string, entry CacheEntry) {
73+
dc.Entries[key] = entry
74+
dc.dirty = true
75+
}
76+
77+
func (dc *DownloadCache) Save() error {
78+
if !dc.dirty {
79+
return nil
80+
}
81+
if err := os.MkdirAll(filepath.Dir(dc.path), 0o700); err != nil {
82+
return err
83+
}
84+
data, err := json.Marshal(dc)
85+
if err != nil {
86+
return err
87+
}
88+
if err := os.WriteFile(dc.path, data, 0o600); err != nil {
89+
return err
90+
}
91+
dc.dirty = false
92+
return nil
93+
}
94+
95+
// CacheKey builds a deterministic key by hashing the full download parameters.
96+
// It uses reflection to extract actual values from optional fields since
97+
// antihax/optional types don't serialize meaningfully via json.Marshal.
98+
func CacheKey(projectID, localeID string, opts phrase.LocaleDownloadOpts) string {
99+
// Zero out conditional request fields so they don't affect the key.
100+
opts.IfNoneMatch = optional.String{}
101+
opts.IfModifiedSince = optional.String{}
102+
103+
raw := fmt.Sprintf("%s/%s/%s", projectID, localeID, serializeOpts(opts))
104+
h := sha256.Sum256([]byte(raw))
105+
return fmt.Sprintf("%x", h[:12])
106+
}
107+
108+
// serializeOpts extracts set values from optional fields into a deterministic map.
109+
// It assumes all fields in LocaleDownloadOpts are either slices or antihax/optional
110+
// types with IsSet()/Value() methods. Fields with other types are silently excluded.
111+
func serializeOpts(opts phrase.LocaleDownloadOpts) string {
112+
v := reflect.ValueOf(opts)
113+
t := v.Type()
114+
m := make(map[string]interface{})
115+
116+
for i := 0; i < t.NumField(); i++ {
117+
field := v.Field(i)
118+
name := t.Field(i).Name
119+
120+
// Handle slices directly
121+
if field.Kind() == reflect.Slice {
122+
if field.Len() > 0 {
123+
m[name] = field.Interface()
124+
}
125+
continue
126+
}
127+
128+
// For optional types, check IsSet and extract Value
129+
isSetMethod := field.MethodByName("IsSet")
130+
valueMethod := field.MethodByName("Value")
131+
if isSetMethod.IsValid() && valueMethod.IsValid() {
132+
results := isSetMethod.Call(nil)
133+
if len(results) > 0 && results[0].Bool() {
134+
m[name] = valueMethod.Call(nil)[0].Interface()
135+
}
136+
}
137+
}
138+
139+
data, err := json.Marshal(m)
140+
if err != nil {
141+
return fmt.Sprintf("%v", m)
142+
}
143+
return string(data)
144+
}
145+
146+
func cachePath() string {
147+
dir, err := os.UserCacheDir()
148+
if err != nil {
149+
dir = os.TempDir()
150+
}
151+
return filepath.Join(dir, cacheDirName, cacheFileName)
152+
}

0 commit comments

Comments
 (0)