diff --git a/.gitignore b/.gitignore index 80351d2..eb6fc7d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ *.dll *.so *.dylib + poe2arb +!poe2arb/ # Test binary, built with `go test -c` *.test @@ -24,4 +26,4 @@ go.work # Goreleaser dist/ -.vscode \ No newline at end of file +.vscode diff --git a/README.md b/README.md index 8298141..7fb6117 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,22 @@ Currently, only an stdin/stdout is supported for the `poe2arb convert` command. poe2arb convert io --lang en < Hello_World_English.json > lib/l10n/app_en.arb ``` +### Seeding POEditor project + +**EXPERIMENTAL FEATURE** + +If you're setting up a project from some template code, you probably already have some ARB files that need +to be imported into the POEditor project. Using the POEditor's built-in tool won't give a satisfying result, +as it will completely ignore placeholders along with their types and other parameters, as well as it won't +"understand" the plural ICU message format. This is where `poe2arb seed` command comes into place. + +`poe2arb seed` command uses the same configuration as the `poe2arb poe`, but **it needs API access token with a write +access**, to create language in the project if needed, and to upload the translations and terms. + +This command is meant only for seeding the project, i.e. setting its first contents. It won't override your existing +translations and won't delete anything. That said, it should still be run with caution and running this on projects +with already populated translations is inadvisable. + ## Syntax & supported features Term name must be a valid Dart field name, additionaly, it must start with a diff --git a/cmd/convert.go b/cmd/convert.go index 5091af9..09c866e 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -3,7 +3,7 @@ package cmd import ( "os" - "github.com/leancodepl/poe2arb/converter" + "github.com/leancodepl/poe2arb/convert/poe2arb" "github.com/spf13/cobra" ) @@ -40,7 +40,7 @@ func runConvertIo(cmd *cobra.Command, args []string) error { noTemplate, _ := cmd.Flags().GetBool(noTemplateFlag) termPrefix, _ := cmd.Flags().GetString(termPrefixFlag) - conv := converter.NewConverter(os.Stdin, &converter.ConverterOptions{ + conv := poe2arb.NewConverter(os.Stdin, &poe2arb.ConverterOptions{ Lang: lang, Template: !noTemplate, RequireResourceAttributes: true, diff --git a/cmd/poe.go b/cmd/poe.go index d9b2e7a..b202d6a 100644 --- a/cmd/poe.go +++ b/cmd/poe.go @@ -8,7 +8,7 @@ import ( "regexp" "strings" - "github.com/leancodepl/poe2arb/converter" + "github.com/leancodepl/poe2arb/convert/poe2arb" "github.com/leancodepl/poe2arb/flutter" "github.com/leancodepl/poe2arb/log" "github.com/leancodepl/poe2arb/poeditor" @@ -242,7 +242,7 @@ func (c *poeCommand) ExportLanguage(lang poeditor.Language, template bool) error convertLogSub := logSub.Info("converting JSON to ARB").Sub() - conv := converter.NewConverter(resp.Body, &converter.ConverterOptions{ + conv := poe2arb.NewConverter(resp.Body, &poe2arb.ConverterOptions{ Lang: lang.Code, Template: template, RequireResourceAttributes: c.options.RequireResourceAttributes, diff --git a/cmd/poe2arb.go b/cmd/poe2arb.go index b658d5c..a231477 100644 --- a/cmd/poe2arb.go +++ b/cmd/poe2arb.go @@ -21,6 +21,7 @@ const loggerKey = ctxKey(1) func Execute(logger *log.Logger) { rootCmd.AddCommand(convertCmd) rootCmd.AddCommand(poeCmd) + rootCmd.AddCommand(seedCmd) rootCmd.AddCommand(versionCmd) ctx := context.WithValue(context.Background(), loggerKey, logger) diff --git a/cmd/seed.go b/cmd/seed.go new file mode 100644 index 0000000..335fd9f --- /dev/null +++ b/cmd/seed.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/leancodepl/poe2arb/convert/arb2poe" + "github.com/leancodepl/poe2arb/poeditor" + "github.com/spf13/cobra" +) + +var seedCmd = &cobra.Command{ + Use: "seed", + Short: "EXPERIMENTAL! Seeds POEditor with data from ARBs. To be used only on empty projects.", + SilenceErrors: true, + SilenceUsage: true, + RunE: runSeed, +} + +func init() { + seedCmd.Flags().StringP(projectIDFlag, "p", "", "POEditor project ID") + seedCmd.Flags().StringP(tokenFlag, "t", "", "POEditor API token") + seedCmd.Flags().StringP(termPrefixFlag, "", "", "POEditor term prefix") + seedCmd.Flags().StringP(outputDirFlag, "o", "", `Output directory [default: "."]`) + seedCmd.Flags().StringSliceP(overrideLangsFlag, "", []string{}, "Override downloaded languages") +} + +func runSeed(cmd *cobra.Command, args []string) error { + log := getLogger(cmd) + + fileLog := log.Info("loading options").Sub() + + sel, err := getOptionsSelector(cmd) + if err != nil { + fileLog.Error("failed: " + err.Error()) + return err + } + + options, err := sel.SelectOptions() + if err != nil { + fileLog.Error("failed: " + err.Error()) + return err + } + + fileLog = log.Info("reading ARB files in %s", options.OutputDir).Sub() + + var files []string + rawFiles, err := os.ReadDir(options.OutputDir) + if err != nil { + fileLog.Error("failed: " + err.Error()) + return err + } + for _, file := range rawFiles { + if file.IsDir() { + continue + } + + fileName := file.Name() + if !strings.HasPrefix(fileName, options.ARBPrefix) || filepath.Ext(fileName) != ".arb" { + continue + } + + files = append(files, filepath.Join(options.OutputDir, fileName)) + } + + if len(files) == 0 { + fileLog.Error("no ARB files found") + return err + } else { + fileLog.Info("found %d ARB files", len(files)) + } + + poeClient := poeditor.NewClient(options.Token) + + availableLangs, err := poeClient.GetProjectLanguages(options.ProjectID) + if err != nil { + log.Error("failed fetching languages: " + err.Error()) + return err + } + + first := true + for _, filePath := range files { + fileLog = log.Info("seeding %s", filepath.Base(filePath)).Sub() + fileLog.Info("converting ARB to JSON") + + file, err := os.Open(filePath) + if err != nil { + fileLog.Error("failed: " + err.Error()) + return err + } + + converter := arb2poe.NewConverter(file, options.TemplateLocale, options.TermPrefix) + + var b bytes.Buffer + lang, err := converter.Convert(&b) + if err != nil { + if errors.Is(err, arb2poe.NoTermsError) { + fileLog.Info("no terms to convert") + continue + } + + fileLog.Error("failed: " + err.Error()) + return err + } + + if len(options.OverrideLangs) > 0 { + langFound := false + for _, overridenLang := range options.OverrideLangs { + if lang == overridenLang { + langFound = true + break + } + } + + if !langFound { + fileLog.Info("skipping language %s", lang) + continue + } + } + + availableLangFound := false + for _, availableLang := range availableLangs { + if lang == availableLang.Code { + availableLangFound = true + break + } + } + + if !availableLangFound { + langLog := fileLog.Info("adding language %s to project", lang).Sub() + + err = poeClient.AddLanguage(options.ProjectID, lang) + if err != nil { + langLog.Error("failed: " + err.Error()) + return err + } + } + + if !first { + fileLog.Info("waiting 30 seconds to avoid rate limiting") + time.Sleep(30 * time.Second) + } + + uploadLog := fileLog.Info("uploading JSON to POEditor").Sub() + + err = poeClient.Upload(options.ProjectID, lang, &b) + if err != nil { + uploadLog.Error("failed: " + err.Error()) + return err + } else { + fileLog.Success("done") + } + + first = false + } + + return nil +} diff --git a/convert/arb.go b/convert/arb.go new file mode 100644 index 0000000..9f7a4b3 --- /dev/null +++ b/convert/arb.go @@ -0,0 +1,29 @@ +// Package convert holds structures common to both directions of conversion. +package convert + +import ( + orderedmap "github.com/wk8/go-ordered-map/v2" +) + +const LocaleKey = "@@locale" + +type ARBMessage struct { + Name string + Translation string + Attributes *ARBMessageAttributes +} + +type ARBMessageAttributes struct { + Description string `json:"description,omitempty"` + Placeholders *orderedmap.OrderedMap[string, *ARBPlaceholder] `json:"placeholders,omitempty"` +} + +func (a *ARBMessageAttributes) IsEmpty() bool { + return a.Description == "" && (a.Placeholders == nil || a.Placeholders.Len() == 0) +} + +type ARBPlaceholder struct { + Name string `json:"-"` + Type string `json:"type,omitempty"` + Format string `json:"format,omitempty"` +} diff --git a/convert/arb2poe/arb_message_to_poe_term.go b/convert/arb2poe/arb_message_to_poe_term.go new file mode 100644 index 0000000..3ce6d28 --- /dev/null +++ b/convert/arb2poe/arb_message_to_poe_term.go @@ -0,0 +1,136 @@ +package arb2poe + +import ( + "regexp" + "strings" + + "github.com/leancodepl/poe2arb/convert" + "github.com/pkg/errors" +) + +func arbMessageToPOETerm( + m *convert.ARBMessage, + skipPlaceholderDefinitions bool, + termPrefix string, +) (*convert.POETerm, error) { + translation := m.Translation + if !skipPlaceholderDefinitions && m.Attributes != nil && m.Attributes.Placeholders != nil { + for pair := m.Attributes.Placeholders.Oldest(); pair != nil; pair = pair.Next() { + placeholderName, placeholder := pair.Key, pair.Value + + definitionAppend := "" + if placeholder.Type != "" { + definitionAppend += "," + placeholder.Type + } + if placeholder.Format != "" { + definitionAppend += "," + placeholder.Format + } + + // Only do the replacement for the first occurence (defining the same parameter multiple times is illegal) + found := strings.Index(translation, "{"+placeholderName+"}") + if found == -1 { + continue + } + + index := 1 + found + len(placeholderName) + + translation = translation[:index] + definitionAppend + translation[index:] + } + } + + var definition convert.POETermDefinition + pluralRegexp := regexp.MustCompile(`^{count,\s*plural,\s*(.+)}$`) + if matches := pluralRegexp.FindStringSubmatch(translation); len(matches) > 0 { + pluralDefinition := &convert.POETermPluralDefinition{} + + pluralCategoryRegexp := regexp.MustCompile(`\s*(=0|=1|=2|zero|one|two|few|many|other)\s*{`) + pluralsString := matches[1] + + for { + match := pluralCategoryRegexp.FindStringSubmatch(pluralsString) + if len(match) == 0 { + break + } + + pluralCategory := match[1] + + pluralsString = pluralsString[len(match[0]):] + + depth := 1 + endLength := 0 + for { + findString := pluralsString[endLength:] + + if findString[0] == '\\' { + // escape character, ignore next bracket + endLength += 2 + continue + } else if findString[0] == '{' { + depth++ + } else if findString[0] == '}' { + depth-- + } + + endLength++ + + if depth == 0 { + break + } + + } + + pluralDefinitionValue := pluralsString[:endLength-1] // -1 to remove the closing bracket + switch pluralCategory { + case "=0", "zero": + if pluralDefinition.Zero != nil { + return nil, errors.New("multiple definitions for plural category zero") + } + + pluralDefinition.Zero = &pluralDefinitionValue + case "=1", "one": + if pluralDefinition.One != nil { + return nil, errors.New("multiple definitions for plural category one") + } + + pluralDefinition.One = &pluralDefinitionValue + case "=2", "two": + if pluralDefinition.Two != nil { + return nil, errors.New("multiple definitions for plural category two") + } + + pluralDefinition.Two = &pluralDefinitionValue + case "few": + pluralDefinition.Few = &pluralDefinitionValue + case "many": + pluralDefinition.Many = &pluralDefinitionValue + case "other": + pluralDefinition.Other = pluralDefinitionValue + } + + pluralsString = pluralsString[endLength:] + } + + definition = convert.POETermDefinition{ + IsPlural: true, + Plural: pluralDefinition, + } + } else { + definition = convert.POETermDefinition{Value: &translation} + } + + var termPlural string + if definition.IsPlural { + termPlural = "." + } + + termName := m.Name + if termPrefix != "" { + termName = termPrefix + ":" + termName + } + + return &convert.POETerm{ + Term: termName, + TermPlural: termPlural, + Definition: definition, + }, nil +} diff --git a/convert/arb2poe/arb_parser.go b/convert/arb2poe/arb_parser.go new file mode 100644 index 0000000..5aad6dd --- /dev/null +++ b/convert/arb2poe/arb_parser.go @@ -0,0 +1,79 @@ +package arb2poe + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/leancodepl/poe2arb/convert" + "github.com/pkg/errors" + orderedmap "github.com/wk8/go-ordered-map/v2" +) + +func parseARB(r io.Reader) (lang string, messages []*convert.ARBMessage, err error) { + var arb map[string]any + err = json.NewDecoder(r).Decode(&arb) + if err != nil { + err = errors.Wrap(err, "failed to decode ARB") + return "", nil, err + } + + lang, ok := arb[convert.LocaleKey].(string) + if !ok { + err = errors.New("missing locale key") + return "", nil, err + } + + for key, value := range arb { + if strings.HasPrefix(key, "@") { + continue + } + + var translation string + if translation, ok = value.(string); !ok { + err = errors.Errorf("invalid translation value for %s", key) + return "", nil, err + } + + message := &convert.ARBMessage{ + Name: key, + Translation: translation, + } + + if attrs, ok := arb["@"+key].(map[string]any); ok { + encoded, err := json.Marshal(attrs) + if err != nil { + return "", nil, errors.Wrap(err, fmt.Sprintf("failed to encode attributes for %s", key)) + } + + var attributes struct { + Placeholders map[string]struct { + Type string `json:"type,omitempty"` + Format string `json:"format,omitempty"` + } `json:"placeholders,omitempty"` + } + err = json.Unmarshal(encoded, &attributes) + if err != nil { + return "", nil, errors.Wrap(err, fmt.Sprintf("failed to decode attributes for %s", key)) + } + + attrsOm := orderedmap.New[string, *convert.ARBPlaceholder]() + for placeholderName, placeholder := range attributes.Placeholders { + attrsOm.Set(placeholderName, &convert.ARBPlaceholder{ + Name: placeholderName, + Type: placeholder.Type, + Format: placeholder.Format, + }) + } + + message.Attributes = &convert.ARBMessageAttributes{ + Placeholders: attrsOm, + } + } + + messages = append(messages, message) + } + + return +} diff --git a/convert/arb2poe/arb_parser_test.go b/convert/arb2poe/arb_parser_test.go new file mode 100644 index 0000000..6fe80b1 --- /dev/null +++ b/convert/arb2poe/arb_parser_test.go @@ -0,0 +1,19 @@ +package arb2poe + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseARB(t *testing.T) { + file, _ := os.Open("testdata/english.arb") + defer file.Close() + + lang, messages, err := parseARB(file) + + assert.Equal(t, "en", lang) + assert.NotEmpty(t, messages) + assert.NoError(t, err) +} diff --git a/convert/arb2poe/converter.go b/convert/arb2poe/converter.go new file mode 100644 index 0000000..7023b6c --- /dev/null +++ b/convert/arb2poe/converter.go @@ -0,0 +1,57 @@ +// Package arb2poe provides a converter from ARB to POEditor's JSON with params support. +package arb2poe + +import ( + "encoding/json" + "io" + + "github.com/leancodepl/poe2arb/convert" + "github.com/pkg/errors" +) + +type Converter struct { + input io.Reader + + templateLocale string + termPrefix string +} + +func NewConverter(input io.Reader, templateLocale, termPrefix string) *Converter { + return &Converter{ + input: input, + templateLocale: templateLocale, + termPrefix: termPrefix, + } +} + +var NoTermsError = errors.New("no terms to convert") + +func (c *Converter) Convert(output io.Writer) (lang string, err error) { + lang, messages, err := parseARB(c.input) + if err != nil { + return "", errors.Wrap(err, "failed to parse ARB") + } + + template := c.templateLocale == lang + + var poeTerms []*convert.POETerm + for _, message := range messages { + poeTerm, err := arbMessageToPOETerm(message, !template, c.termPrefix) + if err != nil { + return "", errors.Wrapf(err, "decoding term %q failed", message.Name) + } + + poeTerms = append(poeTerms, poeTerm) + } + + if len(poeTerms) == 0 { + return "", NoTermsError + } + + err = json.NewEncoder(output).Encode(poeTerms) + if err != nil { + return "", errors.Wrap(err, "failed to encode POEditor JSON") + } + + return lang, nil +} diff --git a/convert/arb2poe/testdata/english.arb b/convert/arb2poe/testdata/english.arb new file mode 100644 index 0000000..08e29b2 --- /dev/null +++ b/convert/arb2poe/testdata/english.arb @@ -0,0 +1,72 @@ +{ + "@@locale": "en", + "simpleTerm": "Hello, world!", + "termOnlyInTemplate": "Hello!", + "termOnlyInNonTemplate": "", + "pluralNoParams": "{count, plural, =1 {One thing} other {Many things}}", + "@pluralNoParams": { + "placeholders": { + "count": {} + } + }, + "pluralCountParam": "{count, plural, =1 {{count} apple} other {{count} apples}}", + "@pluralCountParam": { + "placeholders": { + "count": {} + } + }, + "pluralComplexParams": "{count, plural, =1 {{count} apple and {oranges}, I repeat, {oranges} oranges} other {{count} apples and {oranges}, I repeat, {oranges} oranges}}", + "@pluralComplexParams": { + "placeholders": { + "count": {}, + "oranges": { + "type": "int" + } + } + }, + "params": "{simple} param, {definedDate} date, {someCount} integer, {percentage} percentage", + "@params": { + "placeholders": { + "simple": { + "type": "String" + }, + "definedDate": { + "type": "DateTime", + "format": "yMd" + }, + "someCount": { + "type": "int" + }, + "percentage": { + "type": "double", + "format": "percentPattern" + } + } + }, + "repeatedParams": "{simple} param, {definedDate} date, {someCount} integer and {definedDate} date once again", + "@repeatedParams": { + "placeholders": { + "simple": { + "type": "String" + }, + "definedDate": { + "type": "DateTime", + "format": "yMd" + }, + "someCount": { + "type": "int" + } + } + }, + "redefinedParams": "{simple}, {aNumber}", + "@redefinedParams": { + "placeholders": { + "simple": { + "type": "String" + }, + "aNumber": { + "type": "int" + } + } + } +} diff --git a/converter/arb_test.go b/convert/arb_test.go similarity index 66% rename from converter/arb_test.go rename to convert/arb_test.go index a001f97..fe673cb 100644 --- a/converter/arb_test.go +++ b/convert/arb_test.go @@ -1,4 +1,4 @@ -package converter +package convert import ( "testing" @@ -10,36 +10,36 @@ import ( func TestArbMessageAttributesIsEmpty(t *testing.T) { type testCase struct { Name string - Attributes arbMessageAttributes + Attributes ARBMessageAttributes Expected bool } - nonEmptyMap := orderedmap.New[string, *arbPlaceholder]() - nonEmptyMap.Set("foo", &arbPlaceholder{Name: "foo"}) + nonEmptyMap := orderedmap.New[string, *ARBPlaceholder]() + nonEmptyMap.Set("foo", &ARBPlaceholder{Name: "foo"}) testCases := []testCase{ { "all empty", - arbMessageAttributes{}, + ARBMessageAttributes{}, true, }, { "empty placeholders", - arbMessageAttributes{ - Placeholders: orderedmap.New[string, *arbPlaceholder](), + ARBMessageAttributes{ + Placeholders: orderedmap.New[string, *ARBPlaceholder](), }, true, }, { "non-empty description", - arbMessageAttributes{ + ARBMessageAttributes{ Description: "foo", }, false, }, { "non-empty placeholders", - arbMessageAttributes{ + ARBMessageAttributes{ Placeholders: nonEmptyMap, }, false, diff --git a/converter/json_term.go b/convert/json_term.go similarity index 64% rename from converter/json_term.go rename to convert/json_term.go index bc34403..9b9cf20 100644 --- a/converter/json_term.go +++ b/convert/json_term.go @@ -1,4 +1,4 @@ -package converter +package convert import ( "encoding/json" @@ -6,19 +6,20 @@ import ( "fmt" ) -type jsonTerm struct { - Term string `json:"term"` - Definition jsonTermDefinition `json:"definition"` +type POETerm struct { + Term string `json:"term"` + TermPlural string `json:"term_plural"` + Definition POETermDefinition `json:"definition"` } -type jsonTermDefinition struct { +type POETermDefinition struct { IsPlural bool Value *string - Plural *jsonTermPluralDefinition + Plural *POETermPluralDefinition } -func (d *jsonTermDefinition) UnmarshalJSON(data []byte) error { +func (d *POETermDefinition) UnmarshalJSON(data []byte) error { var v interface{} if err := json.Unmarshal(data, &v); err != nil { return err @@ -40,16 +41,24 @@ func (d *jsonTermDefinition) UnmarshalJSON(data []byte) error { return errors.New("invalid definition type") } -type jsonTermPluralDefinition struct { - Zero *string `json:"zero"` - One *string `json:"one"` - Two *string `json:"two"` - Few *string `json:"few"` - Many *string `json:"many"` +func (d POETermDefinition) MarshalJSON() ([]byte, error) { + if d.IsPlural { + return json.Marshal(d.Plural) + } + + return json.Marshal(d.Value) +} + +type POETermPluralDefinition struct { + Zero *string `json:"zero,omitempty"` + One *string `json:"one,omitempty"` + Two *string `json:"two,omitempty"` + Few *string `json:"few,omitempty"` + Many *string `json:"many,omitempty"` Other string `json:"other"` } -func (p jsonTermPluralDefinition) Map(mapper func(string) (string, error)) (*jsonTermPluralDefinition, error) { +func (p POETermPluralDefinition) Map(mapper func(string) (string, error)) (*POETermPluralDefinition, error) { var zero, one, two, few, many *string if p.Zero != nil { @@ -93,14 +102,14 @@ func (p jsonTermPluralDefinition) Map(mapper func(string) (string, error)) (*jso return nil, err } - return &jsonTermPluralDefinition{ + return &POETermPluralDefinition{ Zero: zero, One: one, Two: two, Few: few, Many: many, Other: v, }, nil } -func (p jsonTermPluralDefinition) ToICUMessageFormat() string { +func (p POETermPluralDefinition) ToICUMessageFormat() string { str := "{count, plural," if p.Zero != nil { str += fmt.Sprintf(" =0 {%s}", *p.Zero) diff --git a/converter/json_term_test.go b/convert/json_term_test.go similarity index 87% rename from converter/json_term_test.go rename to convert/json_term_test.go index 05a229f..381e932 100644 --- a/converter/json_term_test.go +++ b/convert/json_term_test.go @@ -1,4 +1,4 @@ -package converter +package convert import ( "encoding/json" @@ -16,7 +16,7 @@ func TestJSONTermDefinitionUnmarshalJSON(t *testing.T) { ExpectedValueExists bool ExpectedValue string ExpectedPluralExists bool - ExpectedPlural jsonTermPluralDefinition + ExpectedPlural POETermPluralDefinition } cases := []testCase{ @@ -36,7 +36,7 @@ func TestJSONTermDefinitionUnmarshalJSON(t *testing.T) { Name: "plural with only other", Input: `{"other": "Something"}`, ExpectedPluralExists: true, - ExpectedPlural: jsonTermPluralDefinition{ + ExpectedPlural: POETermPluralDefinition{ Other: "Something", }, }, @@ -45,7 +45,7 @@ func TestJSONTermDefinitionUnmarshalJSON(t *testing.T) { Input: `{"zero": "Zero", "one": "One", "two": "Two", "few": "Few", "many": "Many", "other": "Other"}`, ExpectedPluralExists: true, - ExpectedPlural: jsonTermPluralDefinition{ + ExpectedPlural: POETermPluralDefinition{ Zero: ptr("Zero"), One: ptr("One"), Two: ptr("Two"), @@ -58,7 +58,7 @@ func TestJSONTermDefinitionUnmarshalJSON(t *testing.T) { for _, testCase := range cases { t.Run(testCase.Name, func(t *testing.T) { - var d jsonTermDefinition + var d POETermDefinition err := json.Unmarshal([]byte(testCase.Input), &d) assert.NoError(t, err) @@ -83,24 +83,24 @@ func TestJSONTermDefinitionUnmarshalJSON(t *testing.T) { func TestJSONTermPluralDefinitionToICUMessageFormat(t *testing.T) { type testCase struct { Name string - Input jsonTermPluralDefinition + Input POETermPluralDefinition ExpectedOutput string } cases := []testCase{ { "only other", - jsonTermPluralDefinition{Other: "test"}, + POETermPluralDefinition{Other: "test"}, "{count, plural, other {test}}", }, { "one and other", - jsonTermPluralDefinition{One: ptr("foobar"), Other: "baz"}, + POETermPluralDefinition{One: ptr("foobar"), Other: "baz"}, "{count, plural, =1 {foobar} other {baz}}", }, { "all", - jsonTermPluralDefinition{ + POETermPluralDefinition{ Zero: ptr("zero"), One: ptr("one"), Two: ptr("two"), Few: ptr("few"), Many: ptr("many"), Other: "other", diff --git a/converter/converter.go b/convert/poe2arb/converter.go similarity index 91% rename from converter/converter.go rename to convert/poe2arb/converter.go index e5bca34..5e7f0aa 100644 --- a/converter/converter.go +++ b/convert/poe2arb/converter.go @@ -1,5 +1,5 @@ -// Package converter handles coversion from POEditor's JSON to Flutter's ARB. -package converter +// Package poe2arb handles coversion from POEditor's JSON to Flutter's ARB. +package poe2arb import ( "encoding/json" @@ -7,6 +7,7 @@ import ( "regexp" "strings" + "github.com/leancodepl/poe2arb/convert" "github.com/pkg/errors" orderedmap "github.com/wk8/go-ordered-map/v2" ) @@ -42,14 +43,14 @@ func NewConverter( } func (c *Converter) Convert(output io.Writer) error { - var jsonContents []*jsonTerm + var jsonContents []*convert.POETerm err := json.NewDecoder(c.input).Decode(&jsonContents) if err != nil { return errors.Wrap(err, "decoding json failed") } arb := orderedmap.New[string, any]() - arb.Set(localeKey, c.lang) + arb.Set(convert.LocaleKey, c.lang) prefixedRegexp := regexp.MustCompile("(?:([a-zA-Z]+):)?(.*)") var errs []error @@ -117,7 +118,7 @@ func errorsToError(errs []error) error { return errors.New(sb.String()) } -func (c Converter) parseTerm(term *jsonTerm) (*arbMessage, error) { +func (c Converter) parseTerm(term *convert.POETerm) (*convert.ARBMessage, error) { var value string tp := newTranslationParser(term.Definition.IsPlural) @@ -152,7 +153,7 @@ func (c Converter) parseTerm(term *jsonTerm) (*arbMessage, error) { value = plural.ToICUMessageFormat() } - message := &arbMessage{ + message := &convert.ARBMessage{ Name: name, Translation: value, Attributes: tp.BuildMessageAttributes(), diff --git a/converter/converter_test.go b/convert/poe2arb/converter_test.go similarity index 94% rename from converter/converter_test.go rename to convert/poe2arb/converter_test.go index 8e842e7..4e3f0f3 100644 --- a/converter/converter_test.go +++ b/convert/poe2arb/converter_test.go @@ -1,4 +1,4 @@ -package converter_test +package poe2arb_test import ( "bytes" @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/leancodepl/poe2arb/converter" + "github.com/leancodepl/poe2arb/convert/poe2arb" "github.com/stretchr/testify/assert" ) @@ -92,7 +92,7 @@ func convert( termPrefix string, ) (converted string, err error) { reader := strings.NewReader(input) - conv := converter.NewConverter(reader, &converter.ConverterOptions{ + conv := poe2arb.NewConverter(reader, &poe2arb.ConverterOptions{ Lang: "en", Template: template, RequireResourceAttributes: requireResourceAttributes, diff --git a/converter/parser.go b/convert/poe2arb/parser.go similarity index 92% rename from converter/parser.go rename to convert/poe2arb/parser.go index c07ceac..c421807 100644 --- a/converter/parser.go +++ b/convert/poe2arb/parser.go @@ -1,4 +1,4 @@ -package converter +package poe2arb import ( "errors" @@ -6,6 +6,7 @@ import ( "regexp" "strings" + "github.com/leancodepl/poe2arb/convert" orderedmap "github.com/wk8/go-ordered-map/v2" ) @@ -141,15 +142,15 @@ func (tp *translationParser) addPlaceholder(name, placeholderType, format string } } -func (tp *translationParser) BuildMessageAttributes() *arbMessageAttributes { +func (tp *translationParser) BuildMessageAttributes() *convert.ARBMessageAttributes { tp.fallbackPlaceholderTypes() - var placeholders []*arbPlaceholder + var placeholders []*convert.ARBPlaceholder for pair := tp.namedParams.Oldest(); pair != nil; pair = pair.Next() { name, placeholder := pair.Key, pair.Value - arbPlaceholder := &arbPlaceholder{ + arbPlaceholder := &convert.ARBPlaceholder{ Name: name, Type: placeholder.Type, Format: placeholder.Format, @@ -159,15 +160,15 @@ func (tp *translationParser) BuildMessageAttributes() *arbMessageAttributes { } // json:omitempty isn't possible for custom structs, so return nil on empty - var placeholdersMap *orderedmap.OrderedMap[string, *arbPlaceholder] + var placeholdersMap *orderedmap.OrderedMap[string, *convert.ARBPlaceholder] if len(placeholders) > 0 { - placeholdersMap = orderedmap.New[string, *arbPlaceholder]() + placeholdersMap = orderedmap.New[string, *convert.ARBPlaceholder]() for _, placeholder := range placeholders { placeholdersMap.Set(placeholder.Name, placeholder) } } - return &arbMessageAttributes{Placeholders: placeholdersMap} + return &convert.ARBMessageAttributes{Placeholders: placeholdersMap} } func (tp *translationParser) fallbackPlaceholderTypes() { diff --git a/converter/parser_test.go b/convert/poe2arb/parser_test.go similarity index 99% rename from converter/parser_test.go rename to convert/poe2arb/parser_test.go index cf7be46..ed390ac 100644 --- a/converter/parser_test.go +++ b/convert/poe2arb/parser_test.go @@ -1,4 +1,4 @@ -package converter +package poe2arb import ( "testing" diff --git a/converter/testdata/25-retains-placeholders-order.golden b/convert/testdata/25-retains-placeholders-order.golden similarity index 100% rename from converter/testdata/25-retains-placeholders-order.golden rename to convert/testdata/25-retains-placeholders-order.golden diff --git a/converter/testdata/25-retains-placeholders-order.input b/convert/testdata/25-retains-placeholders-order.input similarity index 100% rename from converter/testdata/25-retains-placeholders-order.input rename to convert/testdata/25-retains-placeholders-order.input diff --git a/converter/testdata/attributes-req-attrs.golden b/convert/testdata/attributes-req-attrs.golden similarity index 100% rename from converter/testdata/attributes-req-attrs.golden rename to convert/testdata/attributes-req-attrs.golden diff --git a/converter/testdata/attributes-req-attrs.input b/convert/testdata/attributes-req-attrs.input similarity index 100% rename from converter/testdata/attributes-req-attrs.input rename to convert/testdata/attributes-req-attrs.input diff --git a/converter/testdata/attributes.golden b/convert/testdata/attributes.golden similarity index 100% rename from converter/testdata/attributes.golden rename to convert/testdata/attributes.golden diff --git a/converter/testdata/attributes.input b/convert/testdata/attributes.input similarity index 100% rename from converter/testdata/attributes.input rename to convert/testdata/attributes.input diff --git a/converter/testdata/empty-and-null-translations-no-template.golden b/convert/testdata/empty-and-null-translations-no-template.golden similarity index 100% rename from converter/testdata/empty-and-null-translations-no-template.golden rename to convert/testdata/empty-and-null-translations-no-template.golden diff --git a/converter/testdata/empty-and-null-translations-no-template.input b/convert/testdata/empty-and-null-translations-no-template.input similarity index 100% rename from converter/testdata/empty-and-null-translations-no-template.input rename to convert/testdata/empty-and-null-translations-no-template.input diff --git a/converter/testdata/filter-noprefix.golden b/convert/testdata/filter-noprefix.golden similarity index 100% rename from converter/testdata/filter-noprefix.golden rename to convert/testdata/filter-noprefix.golden diff --git a/converter/testdata/filter-noprefix.input b/convert/testdata/filter-noprefix.input similarity index 100% rename from converter/testdata/filter-noprefix.input rename to convert/testdata/filter-noprefix.input diff --git a/converter/testdata/filter-prefix.golden b/convert/testdata/filter-prefix.golden similarity index 100% rename from converter/testdata/filter-prefix.golden rename to convert/testdata/filter-prefix.golden diff --git a/converter/testdata/filter-prefix.input b/convert/testdata/filter-prefix.input similarity index 100% rename from converter/testdata/filter-prefix.input rename to convert/testdata/filter-prefix.input diff --git a/converter/testdata/named-placeholder-no-template.golden b/convert/testdata/named-placeholder-no-template.golden similarity index 100% rename from converter/testdata/named-placeholder-no-template.golden rename to convert/testdata/named-placeholder-no-template.golden diff --git a/converter/testdata/named-placeholder-no-template.input b/convert/testdata/named-placeholder-no-template.input similarity index 100% rename from converter/testdata/named-placeholder-no-template.input rename to convert/testdata/named-placeholder-no-template.input diff --git a/converter/testdata/named-placeholder.golden b/convert/testdata/named-placeholder.golden similarity index 100% rename from converter/testdata/named-placeholder.golden rename to convert/testdata/named-placeholder.golden diff --git a/converter/testdata/named-placeholder.input b/convert/testdata/named-placeholder.input similarity index 100% rename from converter/testdata/named-placeholder.input rename to convert/testdata/named-placeholder.input diff --git a/converter/testdata/null-definition.golden b/convert/testdata/null-definition.golden similarity index 100% rename from converter/testdata/null-definition.golden rename to convert/testdata/null-definition.golden diff --git a/converter/testdata/null-definition.input b/convert/testdata/null-definition.input similarity index 100% rename from converter/testdata/null-definition.input rename to convert/testdata/null-definition.input diff --git a/converter/testdata/only-text-no-template.golden b/convert/testdata/only-text-no-template.golden similarity index 100% rename from converter/testdata/only-text-no-template.golden rename to convert/testdata/only-text-no-template.golden diff --git a/converter/testdata/only-text-no-template.input b/convert/testdata/only-text-no-template.input similarity index 100% rename from converter/testdata/only-text-no-template.input rename to convert/testdata/only-text-no-template.input diff --git a/converter/testdata/only-text.golden b/convert/testdata/only-text.golden similarity index 100% rename from converter/testdata/only-text.golden rename to convert/testdata/only-text.golden diff --git a/converter/testdata/only-text.input b/convert/testdata/only-text.input similarity index 100% rename from converter/testdata/only-text.input rename to convert/testdata/only-text.input diff --git a/converter/testdata/positional-placeholder.golden b/convert/testdata/positional-placeholder.golden similarity index 100% rename from converter/testdata/positional-placeholder.golden rename to convert/testdata/positional-placeholder.golden diff --git a/converter/testdata/positional-placeholder.input b/convert/testdata/positional-placeholder.input similarity index 100% rename from converter/testdata/positional-placeholder.input rename to convert/testdata/positional-placeholder.input diff --git a/converter/testdata/text-with-double-quotes.golden b/convert/testdata/text-with-double-quotes.golden similarity index 100% rename from converter/testdata/text-with-double-quotes.golden rename to convert/testdata/text-with-double-quotes.golden diff --git a/converter/testdata/text-with-double-quotes.input b/convert/testdata/text-with-double-quotes.input similarity index 100% rename from converter/testdata/text-with-double-quotes.input rename to convert/testdata/text-with-double-quotes.input diff --git a/converter/testdata/two-distinct-named-placeholders.golden b/convert/testdata/two-distinct-named-placeholders.golden similarity index 100% rename from converter/testdata/two-distinct-named-placeholders.golden rename to convert/testdata/two-distinct-named-placeholders.golden diff --git a/converter/testdata/two-distinct-named-placeholders.input b/convert/testdata/two-distinct-named-placeholders.input similarity index 100% rename from converter/testdata/two-distinct-named-placeholders.input rename to convert/testdata/two-distinct-named-placeholders.input diff --git a/converter/testdata/two-same-named-placeholders.golden b/convert/testdata/two-same-named-placeholders.golden similarity index 100% rename from converter/testdata/two-same-named-placeholders.golden rename to convert/testdata/two-same-named-placeholders.golden diff --git a/converter/testdata/two-same-named-placeholders.input b/convert/testdata/two-same-named-placeholders.input similarity index 100% rename from converter/testdata/two-same-named-placeholders.input rename to convert/testdata/two-same-named-placeholders.input diff --git a/converter/arb.go b/converter/arb.go deleted file mode 100644 index be24c9d..0000000 --- a/converter/arb.go +++ /dev/null @@ -1,26 +0,0 @@ -package converter - -import orderedmap "github.com/wk8/go-ordered-map/v2" - -const localeKey = "@@locale" - -type arbMessage struct { - Name string - Translation string - Attributes *arbMessageAttributes -} - -type arbMessageAttributes struct { - Description string `json:"description,omitempty"` - Placeholders *orderedmap.OrderedMap[string, *arbPlaceholder] `json:"placeholders,omitempty"` -} - -func (a *arbMessageAttributes) IsEmpty() bool { - return a.Description == "" && (a.Placeholders == nil || a.Placeholders.Len() == 0) -} - -type arbPlaceholder struct { - Name string `json:"-"` - Type string `json:"type,omitempty"` - Format string `json:"format,omitempty"` -} diff --git a/poeditor/client.go b/poeditor/client.go index 86cf7e9..181fe92 100644 --- a/poeditor/client.go +++ b/poeditor/client.go @@ -4,9 +4,11 @@ package poeditor import ( + "bytes" "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "strings" @@ -74,6 +76,20 @@ func handleRequestErr(err error, resp baseResponse) error { return TryNewErrorFromResponse(resp.Response) } +func (c *Client) AddLanguage(projectID, languageCode string) error { + var resp baseResponse + params := map[string]string{ + "id": projectID, + "language": languageCode, + } + err := c.request("/languages/add", params, &resp) + if err := handleRequestErr(err, resp); err != nil { + return err + } + + return nil +} + func (c *Client) GetProjectLanguages(projectID string) ([]Language, error) { var resp languagesListResponse @@ -109,3 +125,53 @@ func (c *Client) GetExportURL(projectID, languageCode string) (string, error) { return resp.Result.URL, nil } + +func (c *Client) Upload(projectID, languageCode string, file io.Reader) error { + url := fmt.Sprintf("%s%s", c.apiURL, "/projects/upload") + + var b bytes.Buffer + w := multipart.NewWriter(&b) + w.WriteField("api_token", c.token) + w.WriteField("id", projectID) + w.WriteField("updating", "terms_translations") + + fileWriter, err := w.CreateFormFile("file", "file.json") + if err != nil { + return errors.Wrap(err, "creating form field") + } + if _, err = io.Copy(fileWriter, file); err != nil { + return errors.Wrap(err, "copying file to form field") + } + + w.WriteField("language", languageCode) + w.WriteField("overwrite", "0") + + err = w.Close() + if err != nil { + return errors.Wrap(err, "closing multipart writer") + } + + req, err := http.NewRequest("POST", url, &b) + if err != nil { + return errors.Wrap(err, "creating HTTP request") + } + + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := c.client.Do(req) + if err != nil { + return errors.Wrap(err, "making HTTP request") + } + + var respBody baseResponse + err = json.NewDecoder(resp.Body).Decode(&respBody) + if err != nil { + return errors.Wrap(err, "decoding response") + } + + if err := handleRequestErr(err, respBody); err != nil { + return err + } + + return nil +}