@@ -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,150 @@ 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.RWMutex
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 .downloadWithRateGate (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+ // downloadWithRateGate downloads a locale file with rate-limit coordination.
245+ // Uses RWMutex as a broadcast gate: workers take a read lock (cheap, concurrent),
246+ // and a rate-limited worker takes the write lock to pause everyone until reset.
247+ func (target * Target ) downloadWithRateGate (client * phrase.APIClient , localeFile * LocaleFile , opts phrase.LocaleDownloadOpts , gate * sync.RWMutex ) error {
248+ // Read-lock gate: blocks only when a writer (rate-limited worker) holds it
249+ gate .RLock ()
250+ gate .RUnlock ()
251+
252+ file , response , err := client .LocalesApi .LocaleDownload (Auth , target .ProjectID , localeFile .ID , & opts )
253+ if err != nil {
254+ if response != nil && response .Rate .Remaining == 0 {
255+ // TryLock ensures only one worker handles the rate limit pause.
256+ // Others will block on their next RLock until the pause is over.
257+ if gate .TryLock () {
258+ waitForRateLimit (response .Rate )
259+ gate .Unlock ()
260+ } else {
261+ // Another worker is already pausing; wait for it
262+ gate .RLock ()
263+ gate .RUnlock ()
264+ }
265+
266+ file , _ , err = client .LocalesApi .LocaleDownload (Auth , target .ProjectID , localeFile .ID , & opts )
267+ if err != nil {
268+ return err
269+ }
270+ } else {
271+ return err
272+ }
273+ }
274+ return copyToDestination (file , localeFile .Path )
275+ }
276+
277+ // buildDownloadOpts prepares the LocaleDownloadOpts for a locale file download.
278+ func (target * Target ) buildDownloadOpts (localeFile * LocaleFile ) (phrase.LocaleDownloadOpts , error ) {
279+ opts := phrase.LocaleDownloadOpts {}
151280
152281 if target .Params != nil {
153- localVarOptionals = target .Params .LocaleDownloadOpts
282+ opts = target .Params .LocaleDownloadOpts
154283 translationKeyPrefix , err := placeholders .ResolveTranslationKeyPrefix (target .Params .TranslationKeyPrefix , localeFile .Path )
155284 if err != nil {
156- return err
285+ return opts , err
157286 }
158- localVarOptionals .TranslationKeyPrefix = translationKeyPrefix
287+ opts .TranslationKeyPrefix = translationKeyPrefix
159288 }
160289
161- if localVarOptionals .FileFormat .Value () == "" {
162- localVarOptionals .FileFormat = optional .NewString (localeFile .FileFormat )
290+ if opts .FileFormat .Value () == "" {
291+ opts .FileFormat = optional .NewString (localeFile .FileFormat )
163292 }
164293
165294 if localeFile .Tag != "" {
166- localVarOptionals .Tags = optional .NewString (localeFile .Tag )
167- localVarOptionals .Tag = optional .EmptyString ()
295+ opts .Tags = optional .NewString (localeFile .Tag )
296+ opts .Tag = optional .EmptyString ()
297+ }
298+
299+ return opts , nil
300+ }
301+
302+ func (target * Target ) DownloadAndWriteToFile (client * phrase.APIClient , localeFile * LocaleFile , async bool ) error {
303+ localVarOptionals , err := target .buildDownloadOpts (localeFile )
304+ if err != nil {
305+ return err
168306 }
169307
170308 debugFprintln ("Target file pattern:" , target .File )
@@ -182,9 +320,8 @@ func (target *Target) DownloadAndWriteToFile(client *phrase.APIClient, localeFil
182320
183321 if async {
184322 return target .downloadAsynchronously (client , localeFile , localVarOptionals )
185- } else {
186- return target .downloadSynchronously (client , localeFile , localVarOptionals )
187323 }
324+ return target .downloadSynchronously (client , localeFile , localVarOptionals )
188325}
189326
190327func (target * Target ) downloadAsynchronously (client * phrase.APIClient , localeFile * LocaleFile , downloadOpts phrase.LocaleDownloadOpts ) error {
0 commit comments