From 1c00745c60132589b5e674a7adc9f5f60b0a6a7d Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Tue, 31 Mar 2026 17:54:52 +0200 Subject: [PATCH 1/4] feat(go): Add locale_mapping section #SCD-863 --- clients/go/config.go | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/clients/go/config.go b/clients/go/config.go index 5588abc3..eed298b1 100644 --- a/clients/go/config.go +++ b/clients/go/config.go @@ -34,6 +34,8 @@ type Config struct { Pull []byte Push []byte + LocaleMapping map[string]string + UserAgent string } @@ -132,17 +134,19 @@ func configPath() (string, error) { func (cfg *Config) UnmarshalYAML(unmarshal func(i interface{}) error) error { m := map[string]interface{}{} + localeMapping := map[string]interface{}{} err := ParseYAMLToMap(unmarshal, map[string]interface{}{ - "access_token": &cfg.Credentials.Token, - "host": &cfg.Credentials.Host, - "debug": &cfg.Debug, - "page": &cfg.Page, - "per_page": &cfg.PerPage, - "project_id": &cfg.DefaultProjectID, - "file_format": &cfg.DefaultFileFormat, - "push": &cfg.Push, - "pull": &cfg.Pull, - "defaults": &m, + "access_token": &cfg.Credentials.Token, + "host": &cfg.Credentials.Host, + "debug": &cfg.Debug, + "page": &cfg.Page, + "per_page": &cfg.PerPage, + "project_id": &cfg.DefaultProjectID, + "file_format": &cfg.DefaultFileFormat, + "push": &cfg.Push, + "pull": &cfg.Pull, + "locale_mapping": &localeMapping, + "defaults": &m, }) if err != nil { return err @@ -156,6 +160,13 @@ func (cfg *Config) UnmarshalYAML(unmarshal func(i interface{}) error) error { } } + if len(localeMapping) > 0 { + cfg.LocaleMapping, err = ConvertToStringMap(localeMapping) + if err != nil { + return err + } + } + return nil } From 446eb1c7ee5b6d3d6a546ed95c7bd6922f770dec Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Tue, 31 Mar 2026 18:01:01 +0200 Subject: [PATCH 2/4] add test --- clients/go/config_test.go | 5 +++++ clients/go/testdata/config_files/.phrase.yml | 2 ++ 2 files changed, 7 insertions(+) diff --git a/clients/go/config_test.go b/clients/go/config_test.go index ee6febc6..375b6a2b 100644 --- a/clients/go/config_test.go +++ b/clients/go/config_test.go @@ -372,6 +372,11 @@ func TestParseConfig(t *testing.T) { if config.Token != "123" { t.Errorf("Got %s, expected %s", config.Token, "123") } + + if config.LocaleMapping["en"] != "English" { + t.Errorf("Got %s, expected %s", config.LocaleMapping["en"], "English") + } + } func TestParseConfig_fromPath(t *testing.T) { diff --git a/clients/go/testdata/config_files/.phrase.yml b/clients/go/testdata/config_files/.phrase.yml index c4b80a41..dc3819f3 100644 --- a/clients/go/testdata/config_files/.phrase.yml +++ b/clients/go/testdata/config_files/.phrase.yml @@ -1,5 +1,7 @@ phrase: access_token: "123" + locale_mapping: + en: English push: sources: - file: "./config/locales/.yml" From 1b786ac2c5f8a711f3ae543965c21eb2521e9825 Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Tue, 31 Mar 2026 18:06:04 +0200 Subject: [PATCH 3/4] proper test invocation --- .github/workflows/test-go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml index 3d88ab58..554fe896 100644 --- a/.github/workflows/test-go.yml +++ b/.github/workflows/test-go.yml @@ -14,8 +14,8 @@ jobs: run: | npm install npm run generate.go - cd ./clients/go/test - go test + cd ./clients/go + go test ./... -v - name: License check uses: phrase/actions/lawa-ci@v1 with: From b2a9bca01c01b9448f1a940c316fbb6e04df6a26 Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Tue, 31 Mar 2026 18:40:44 +0200 Subject: [PATCH 4/4] feat(CLI): Support locale_mapping on pull #SCD-863 --- clients/cli/cmd/internal/init.go | 17 +- clients/cli/cmd/internal/pull_target.go | 15 +- clients/cli/spec/pull_spec.rb | 404 ++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 clients/cli/spec/pull_spec.rb diff --git a/clients/cli/cmd/internal/init.go b/clients/cli/cmd/internal/init.go index fbad2e42..40fb2826 100644 --- a/clients/cli/cmd/internal/init.go +++ b/clients/cli/cmd/internal/init.go @@ -50,14 +50,15 @@ var stepFuncs = map[string]stepFunc{ // structs that can be marshalled to YAML to create a valid configuration file type ConfigYAML struct { - Host string `yaml:"host,omitempty"` - AccessToken string `yaml:"access_token,omitempty"` - ProjectID string `yaml:"project_id"` - FileFormat string `yaml:"file_format,omitempty"` - PerPage int `yaml:"per_page,omitempty"` - Defaults map[string]map[string]interface{} `yaml:"defaults,omitempty"` - Push PushYAML `yaml:"push,omitempty"` - Pull PullYAML `yaml:"pull,omitempty"` + Host string `yaml:"host,omitempty"` + AccessToken string `yaml:"access_token,omitempty"` + ProjectID string `yaml:"project_id"` + FileFormat string `yaml:"file_format,omitempty"` + PerPage int `yaml:"per_page,omitempty"` + Defaults map[string]map[string]interface{} `yaml:"defaults,omitempty"` + Push PushYAML `yaml:"push,omitempty"` + Pull PullYAML `yaml:"pull,omitempty"` + LocaleMapping map[string]string `yaml:"locale_mapping,omitempty"` } type PushYAML struct { diff --git a/clients/cli/cmd/internal/pull_target.go b/clients/cli/cmd/internal/pull_target.go index 38ee741c..b1b90a99 100644 --- a/clients/cli/cmd/internal/pull_target.go +++ b/clients/cli/cmd/internal/pull_target.go @@ -29,6 +29,7 @@ type Target struct { AccessToken string `json:"access_token"` FileFormat string `json:"file_format"` Params *PullParams `json:"params" mapstructure:"omittable-nested,omitempty"` + LocaleMapping map[string]string RemoteLocales []*phrase.Locale } @@ -118,7 +119,7 @@ func (target *Target) ReplacePlaceholders(localeFile *LocaleFile) (string, error return "", err } - path := strings.Replace(absPath, "", localeFile.Name, -1) + path := strings.Replace(absPath, "", target.applyLocaleMapping(localeFile.Name), -1) path = strings.Replace(path, "", localeFile.Code, -1) path = strings.Replace(path, "", localeFile.Tag, -1) @@ -197,6 +198,7 @@ func TargetsFromConfig(config phrase.Config) (Targets, error) { if target.FileFormat == "" { target.FileFormat = fileFormat } + target.LocaleMapping = config.LocaleMapping validTargets = append(validTargets, target) } @@ -206,3 +208,14 @@ func TargetsFromConfig(config phrase.Config) (Targets, error) { return validTargets, nil } + +// applyLocaleMapping returns the mapped local locale name if a mapping exists, +// otherwise returns the original remote name +func (target *Target) applyLocaleMapping(remoteName string) string { + if target.LocaleMapping != nil { + if localName, ok := target.LocaleMapping[remoteName]; ok { + return localName + } + } + return remoteName +} diff --git a/clients/cli/spec/pull_spec.rb b/clients/cli/spec/pull_spec.rb new file mode 100644 index 00000000..0ae31bf0 --- /dev/null +++ b/clients/cli/spec/pull_spec.rb @@ -0,0 +1,404 @@ +require "spec_helper" +require "fileutils" +require "tmpdir" + +RSpec.describe "phrase pull" do + let(:token) { "test-token-pull" } + let(:project_id) { "test-project-123" } + let(:locale_en_id) { "locale-en-id" } + let(:locale_de_id) { "locale-de-id" } + + let(:en_locale) do + { + id: locale_en_id, + name: "English", + code: "en", + default: true, + main: true, + rtl: false, + plural_forms: ["one", "other"], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z" + } + end + + let(:de_locale) do + { + id: locale_de_id, + name: "German", + code: "de", + default: false, + main: false, + rtl: false, + plural_forms: ["one", "other"], + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z" + } + end + + let(:en_content) do + <<~YAML + en: + hello: "Hello" + world: "World" + YAML + end + + let(:de_content) do + <<~YAML + de: + hello: "Hallo" + world: "Welt" + YAML + end + + around do |example| + Dir.mktmpdir do |tmpdir| + @tmpdir = tmpdir + example.run + ensure + @tmpdir = nil + end + end + + before do + # Clear previous mock requests + mock_clear_requests! + + # Mock the locales list endpoint + mock_set!("GET", "/projects/#{project_id}/locales", + status: 200, + body: [en_locale, de_locale] + ) + + # Mock the download endpoints for each locale + mock_set!("GET", "/projects/#{project_id}/locales/#{locale_en_id}/download", + status: 200, + body: en_content, + headers: { "content-type" => "application/x-yaml" } + ) + + mock_set!("GET", "/projects/#{project_id}/locales/#{locale_de_id}/download", + status: 200, + body: de_content, + headers: { "content-type" => "application/x-yaml" } + ) + end + + describe "basic pull operation" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/locales/.yml" + params: + file_format: yml + YAML + end + + it "downloads locale files successfully" do + r = run_cli("pull", config: config) + + expect(r[:exit_code]).to eq(0) + + # Verify files were created + en_file_path = File.join(@tmpdir, "locales", "en.yml") + de_file_path = File.join(@tmpdir, "locales", "de.yml") + + expect(File.exist?(en_file_path)).to be true + expect(File.exist?(de_file_path)).to be true + + # Verify file contents + expect(File.read(en_file_path)).to eq(en_content) + expect(File.read(de_file_path)).to eq(de_content) + end + + it "makes authenticated requests to the API" do + run_cli("pull", config: config) + + requests_made = mock_requests + + # Should have made: 1 request to list locales + 2 download requests + expect(requests_made.length).to be >= 3 + + # Check locales list request + locales_request = requests_made.find { |r| r["path"] == "/projects/#{project_id}/locales" } + expect(locales_request).not_to be_nil + expect(locales_request["method"]).to eq("GET") + expect(locales_request["headers"]["HTTP_AUTHORIZATION"]).to eq("token #{token}") + + # Check download requests + en_download = requests_made.find { |r| r["path"] == "/projects/#{project_id}/locales/#{locale_en_id}/download" } + de_download = requests_made.find { |r| r["path"] == "/projects/#{project_id}/locales/#{locale_de_id}/download" } + + expect(en_download).not_to be_nil + expect(de_download).not_to be_nil + expect(en_download["headers"]["HTTP_AUTHORIZATION"]).to eq("token #{token}") + expect(de_download["headers"]["HTTP_AUTHORIZATION"]).to eq("token #{token}") + end + end + + describe "pull with locale_name placeholder" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/i18n/.yml" + params: + file_format: yml + YAML + end + + it "uses locale name in file path" do + r = run_cli("pull", config: config) + + expect(r[:exit_code]).to eq(0) + + # Verify files were created with locale names + en_file_path = File.join(@tmpdir, "i18n", "English.yml") + de_file_path = File.join(@tmpdir, "i18n", "German.yml") + + expect(File.exist?(en_file_path)).to be true + expect(File.exist?(de_file_path)).to be true + + # Verify file contents + expect(File.read(en_file_path)).to eq(en_content) + expect(File.read(de_file_path)).to eq(de_content) + end + end + + describe "pull with specific locale filter" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/locales/en.yml" + params: + file_format: yml + locale_id: "#{locale_en_id}" + YAML + end + + it "downloads only the specified locale" do + r = run_cli("pull", config: config) + + expect(r[:exit_code]).to eq(0) + + # Only English file should be created + en_file_path = File.join(@tmpdir, "locales", "en.yml") + + expect(File.exist?(en_file_path)).to be true + expect(File.read(en_file_path)).to eq(en_content) + + # Verify only the English locale was requested + requests_made = mock_requests + download_requests = requests_made.select { |r| r["path"].include?("/download") } + + # Should only have one download request for the en locale + expect(download_requests.length).to eq(1) + expect(download_requests.first["path"]).to include(locale_en_id) + end + end + + describe "pull with tag filter" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/locales/.yml" + params: + file_format: yml + tags: "frontend" + YAML + end + + it "successfully pulls with tag filter configured" do + r = run_cli("pull", config: config) + + expect(r[:exit_code]).to eq(0) + + # Verify files were created + en_file_path = File.join(@tmpdir, "locales", "en.yml") + de_file_path = File.join(@tmpdir, "locales", "de.yml") + + expect(File.exist?(en_file_path)).to be true + expect(File.exist?(de_file_path)).to be true + + # Verify download requests were made + requests_made = mock_requests + download_requests = requests_made.select { |r| r["path"].include?("/download") } + + expect(download_requests).not_to be_empty + end + end + + describe "pull with locale_mapping" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + locale_mapping: + English: "eng" + German: "ger" + pull: + targets: + - file: "#{@tmpdir}/locales/.yml" + params: + file_format: yml + YAML + end + + it "applies locale mapping to file names" do + r = run_cli("pull", config: config) + expect(r[:exit_code]).to eq(0) + + # Verify files were created with mapped locale names + en_file_path = File.join(@tmpdir, "locales", "eng.yml") + de_file_path = File.join(@tmpdir, "locales", "ger.yml") + expect(File.exist?(en_file_path)).to be true + expect(File.exist?(de_file_path)).to be true + + # Verify file contents + expect(File.read(en_file_path)).to eq(en_content) + expect(File.read(de_file_path)).to eq(de_content) + end + end + + describe "error handling" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/locales/.yml" + params: + file_format: yml + YAML + end + + it "handles authentication errors" do + # Override the mock to return 401 + mock_set!("GET", "/projects/#{project_id}/locales", + status: 401, + body: { message: "Unauthorized" } + ) + + r = run_cli("pull", config: config) + + expect(r[:exit_code]).not_to eq(0) + expect(r[:stderr]).to include("401") + end + + it "handles missing project errors" do + # Override the mock to return 404 + mock_set!("GET", "/projects/#{project_id}/locales", + status: 404, + body: { message: "Project not found" } + ) + + r = run_cli("pull", config: config) + + expect(r[:exit_code]).not_to eq(0) + end + end + + describe "pull without configuration" do + it "returns error when no targets are specified" do + config = <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + YAML + + r = run_cli("pull", config: config) + + expect(r[:exit_code]).not_to eq(0) + expect(r[:stderr]).to match(/no targets|pull.*not.*specified/i) + end + end + + describe "pull with multiple targets" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/locales/.yml" + params: + file_format: yml + - file: "#{@tmpdir}/translations/.yaml" + params: + file_format: yml + YAML + end + + it "downloads files to multiple target locations" do + r = run_cli("pull", config: config) + + expect(r[:exit_code]).to eq(0) + + # Check first target (locale_code placeholder) + expect(File.exist?(File.join(@tmpdir, "locales", "en.yml"))).to be true + expect(File.exist?(File.join(@tmpdir, "locales", "de.yml"))).to be true + + # Check second target (locale_name placeholder) + expect(File.exist?(File.join(@tmpdir, "translations", "English.yaml"))).to be true + expect(File.exist?(File.join(@tmpdir, "translations", "German.yaml"))).to be true + end + end + + describe "directory creation" do + let(:config) do + <<~YAML + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "#{project_id}" + access_token: "#{token}" + pull: + targets: + - file: "#{@tmpdir}/deeply/nested/path/locales/.yml" + params: + file_format: yml + YAML + end + + it "creates nested directories automatically" do + r = run_cli("pull", config: config) + + expect(r[:exit_code]).to eq(0) + + # Verify nested directory was created + nested_path = File.join(@tmpdir, "deeply", "nested", "path", "locales") + expect(Dir.exist?(nested_path)).to be true + + # Verify files exist in nested directory + expect(File.exist?(File.join(nested_path, "en.yml"))).to be true + expect(File.exist?(File.join(nested_path, "de.yml"))).to be true + end + end +end