@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "reflect"
1212 "strings"
13+ "sync"
1314 "time"
1415
1516 "github.com/phrase/phrase-cli/cmd/internal/paths"
@@ -18,6 +19,7 @@ import (
1819
1920 "github.com/antihax/optional"
2021 "github.com/phrase/phrase-go/v4"
22+ "golang.org/x/sync/errgroup"
2123)
2224
2325const (
@@ -26,13 +28,16 @@ const (
2628 asyncRetryCount = 360 // 30 minutes
2729)
2830
31+ const maxParallelDownloads = 4 // Phrase API allows max 4 concurrent requests
32+
2933var Config * phrase.Config
3034
3135type PullCommand struct {
3236 phrase.Config
3337 Branch string
3438 UseLocalBranchName bool
3539 Async bool
40+ Parallel bool
3641}
3742
3843var Auth context.Context
@@ -82,7 +87,15 @@ func (cmd *PullCommand) Run(config *phrase.Config) error {
8287 }
8388
8489 for _ , target := range targets {
85- err := target .Pull (client , cmd .Async )
90+ var err error
91+ if cmd .Parallel && ! cmd .Async {
92+ err = target .PullParallel (client )
93+ } else {
94+ if cmd .Parallel && cmd .Async {
95+ print .Warn ("--parallel is not supported with --async, ignoring parallel" )
96+ }
97+ err = target .Pull (client , cmd .Async )
98+ }
8699 if err != nil {
87100 return err
88101 }
@@ -146,25 +159,144 @@ func (target *Target) Pull(client *phrase.APIClient, async bool) error {
146159 return nil
147160}
148161
149- func (target * Target ) DownloadAndWriteToFile (client * phrase.APIClient , localeFile * LocaleFile , async bool ) error {
150- localVarOptionals := phrase.LocaleDownloadOpts {}
162+ type downloadResult struct {
163+ message string
164+ path string
165+ errMsg string
166+ }
167+
168+ func (target * Target ) PullParallel (client * phrase.APIClient ) error {
169+ if err := target .CheckPreconditions (); err != nil {
170+ return err
171+ }
172+
173+ localeFiles , err := target .LocaleFiles ()
174+ if err != nil {
175+ return err
176+ }
177+
178+ // Ensure all destination files/dirs exist before parallel downloads
179+ for _ , lf := range localeFiles {
180+ if err := createFile (lf .Path ); err != nil {
181+ return err
182+ }
183+ }
184+
185+ results := make ([]downloadResult , len (localeFiles ))
186+ var rateMu sync.Mutex
187+
188+ ctx , cancel := context .WithTimeout (context .Background (), timeoutInMinutes )
189+ defer cancel ()
190+ g , ctx := errgroup .WithContext (ctx )
191+ g .SetLimit (maxParallelDownloads )
192+
193+ for i , lf := range localeFiles {
194+ g .Go (func () error {
195+ if ctx .Err () != nil {
196+ return ctx .Err ()
197+ }
198+
199+ opts , err := target .buildDownloadOpts (lf )
200+ if err != nil {
201+ err = fmt .Errorf ("%s for %s" , err , lf .Path )
202+ results [i ] = downloadResult {errMsg : err .Error ()}
203+ return err
204+ }
205+
206+ err = target .downloadWithRateLimitRetry (client , lf , opts , & rateMu )
207+ if err != nil {
208+ if openapiError , ok := err .(phrase.GenericOpenAPIError ); ok {
209+ print .Warn ("API response: %s" , openapiError .Body ())
210+ }
211+ err = fmt .Errorf ("%s for %s" , err , lf .Path )
212+ results [i ] = downloadResult {errMsg : err .Error ()}
213+ return err
214+ }
215+
216+ results [i ] = downloadResult {
217+ message : lf .Message (),
218+ path : lf .RelPath (),
219+ }
220+ return nil
221+ })
222+ }
223+
224+ waitErr := g .Wait ()
225+
226+ // Print results in original order: successes and failures
227+ var skipCount int
228+ for _ , r := range results {
229+ if r .path != "" {
230+ print .Success ("Downloaded %s to %s" , r .message , r .path )
231+ } else if r .errMsg != "" {
232+ print .Failure ("Failed %s" , r .errMsg )
233+ } else {
234+ skipCount ++
235+ }
236+ }
237+ if skipCount > 0 {
238+ print .Warn ("%d download(s) skipped due to earlier failure" , skipCount )
239+ }
240+
241+ return waitErr
242+ }
243+
244+ // downloadWithRateLimitRetry downloads a locale file with rate-limit coordination.
245+ // The shared mutex pauses all workers when any worker hits the API rate limit.
246+ func (target * Target ) downloadWithRateLimitRetry (client * phrase.APIClient , localeFile * LocaleFile , opts phrase.LocaleDownloadOpts , rateMu * sync.Mutex ) error {
247+ // Gate: block if another worker is waiting out a rate limit
248+ rateMu .Lock ()
249+ rateMu .Unlock ()
250+
251+ file , response , err := client .LocalesApi .LocaleDownload (Auth , target .ProjectID , localeFile .ID , & opts )
252+ if err != nil {
253+ if response != nil && response .Rate .Remaining == 0 {
254+ // Hold the mutex while waiting for rate limit reset;
255+ // this blocks other workers from making requests.
256+ rateMu .Lock ()
257+ waitForRateLimit (response .Rate )
258+ rateMu .Unlock ()
259+
260+ file , _ , err = client .LocalesApi .LocaleDownload (Auth , target .ProjectID , localeFile .ID , & opts )
261+ if err != nil {
262+ return err
263+ }
264+ } else {
265+ return err
266+ }
267+ }
268+ return copyToDestination (file , localeFile .Path )
269+ }
270+
271+ // buildDownloadOpts prepares the LocaleDownloadOpts for a locale file download.
272+ func (target * Target ) buildDownloadOpts (localeFile * LocaleFile ) (phrase.LocaleDownloadOpts , error ) {
273+ opts := phrase.LocaleDownloadOpts {}
151274
152275 if target .Params != nil {
153- localVarOptionals = target .Params .LocaleDownloadOpts
276+ opts = target .Params .LocaleDownloadOpts
154277 translationKeyPrefix , err := placeholders .ResolveTranslationKeyPrefix (target .Params .TranslationKeyPrefix , localeFile .Path )
155278 if err != nil {
156- return err
279+ return opts , err
157280 }
158- localVarOptionals .TranslationKeyPrefix = translationKeyPrefix
281+ opts .TranslationKeyPrefix = translationKeyPrefix
159282 }
160283
161- if localVarOptionals .FileFormat .Value () == "" {
162- localVarOptionals .FileFormat = optional .NewString (localeFile .FileFormat )
284+ if opts .FileFormat .Value () == "" {
285+ opts .FileFormat = optional .NewString (localeFile .FileFormat )
163286 }
164287
165288 if localeFile .Tag != "" {
166- localVarOptionals .Tags = optional .NewString (localeFile .Tag )
167- localVarOptionals .Tag = optional .EmptyString ()
289+ opts .Tags = optional .NewString (localeFile .Tag )
290+ opts .Tag = optional .EmptyString ()
291+ }
292+
293+ return opts , nil
294+ }
295+
296+ func (target * Target ) DownloadAndWriteToFile (client * phrase.APIClient , localeFile * LocaleFile , async bool ) error {
297+ localVarOptionals , err := target .buildDownloadOpts (localeFile )
298+ if err != nil {
299+ return err
168300 }
169301
170302 debugFprintln ("Target file pattern:" , target .File )
@@ -182,9 +314,8 @@ func (target *Target) DownloadAndWriteToFile(client *phrase.APIClient, localeFil
182314
183315 if async {
184316 return target .downloadAsynchronously (client , localeFile , localVarOptionals )
185- } else {
186- return target .downloadSynchronously (client , localeFile , localVarOptionals )
187317 }
318+ return target .downloadSynchronously (client , localeFile , localVarOptionals )
188319}
189320
190321func (target * Target ) downloadAsynchronously (client * phrase.APIClient , localeFile * LocaleFile , downloadOpts phrase.LocaleDownloadOpts ) error {
0 commit comments