diff --git a/README.md b/README.md index 9c2b52d8..e6075097 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,54 @@ Here is an example of the default file format that go-i18n supports: To use a different file format, write a parser for the format and add the parsed translations using [AddTranslation](https://godoc.org/github.com/nicksnyder/go-i18n/i18n#AddTranslation). +Flat Format +------------- + +You can also write shorter translation files with flat format. +E.g the example above can be written in this way: + +```json +{ + "d_days": { + "one": "{{.Count}} day.", + "other": "{{.Count}} days." + }, + + "my_height_in_meters": { + "one": "I am {{.Count}} meter tall.", + "other": "I am {{.Count}} meters tall." + }, + + "person_greeting": { + "other": "Hello {{.Person}}" + }, + + "person_unread_email_count": { + "one": "{{.Person}} has {{.Count}} unread email.", + "other": "{{.Person}} has {{.Count}} unread emails." + }, + + "person_unread_email_count_timeframe": { + "one": "{{.Person}} has {{.Count}} unread email in the past {{.Timeframe}}.", + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + + "program_greeting": { + "other": "Hello world" + }, + + "your_unread_email_count": { + "one": "You have {{.Count}} unread email.", + "other": "You have {{.Count}} unread emails." + } +} +``` + +The logic of flat format is, what it is structure of structures +and name of substructures (ids) should be always a string. +If there is only one key in substructure and it is "other", then it's non-plural +translation, else plural. + Contributions ------------- diff --git a/goi18n/constants_command.go b/goi18n/constants_command.go index 85b1ac18..d877add3 100644 --- a/goi18n/constants_command.go +++ b/goi18n/constants_command.go @@ -139,7 +139,6 @@ Options: Default: . `) - os.Exit(1) } // commonInitialisms is a set of common initialisms. diff --git a/goi18n/merge_command.go b/goi18n/merge_command.go index 1d34ac43..184343e5 100644 --- a/goi18n/merge_command.go +++ b/goi18n/merge_command.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "io/ioutil" - "os" "path/filepath" "reflect" "sort" @@ -22,6 +21,7 @@ type mergeCommand struct { sourceLanguage string outdir string format string + flat bool } func (mc *mergeCommand) execute() error { @@ -33,11 +33,6 @@ func (mc *mergeCommand) execute() error { return fmt.Errorf("invalid source locale: %s", mc.sourceLanguage) } - marshal, err := newMarshalFunc(mc.format) - if err != nil { - return err - } - bundle := bundle.New() for _, tf := range mc.translationFiles { if err := bundle.LoadTranslationFile(tf); err != nil { @@ -64,7 +59,7 @@ func (mc *mergeCommand) execute() error { all := filter(localeTranslations, func(t translation.Translation) translation.Translation { return t.Normalize(lang) }) - if err := mc.writeFile("all", all, localeID, marshal); err != nil { + if err := mc.writeFile("all", all, localeID); err != nil { return err } @@ -74,7 +69,7 @@ func (mc *mergeCommand) execute() error { } return nil }) - if err := mc.writeFile("untranslated", untranslated, localeID, marshal); err != nil { + if err := mc.writeFile("untranslated", untranslated, localeID); err != nil { return err } } @@ -88,6 +83,7 @@ func (mc *mergeCommand) parse(arguments []string) { sourceLanguage := flags.String("sourceLanguage", "en-us", "") outdir := flags.String("outdir", ".", "") format := flags.String("format", "json", "") + flat := flags.Bool("flat", true, "") flags.Parse(arguments) @@ -95,31 +91,40 @@ func (mc *mergeCommand) parse(arguments []string) { mc.sourceLanguage = *sourceLanguage mc.outdir = *outdir mc.format = *format + mc.flat = *flat } func (mc *mergeCommand) SetArgs(args []string) { mc.translationFiles = args } -type marshalFunc func(interface{}) ([]byte, error) - -func (mc *mergeCommand) writeFile(label string, translations []translation.Translation, localeID string, marshal marshalFunc) error { +func (mc *mergeCommand) writeFile(label string, translations []translation.Translation, localeID string) error { sort.Sort(translation.SortableByID(translations)) - buf, err := marshal(marshalInterface(translations)) + + var convert func([]translation.Translation) interface{} + if mc.flat { + convert = marshalFlatInterface + } else { + convert = marshalInterface + } + + buf, err := mc.marshal(convert(translations)) if err != nil { return fmt.Errorf("failed to marshal %s strings to %s because %s", localeID, mc.format, err) } + filename := filepath.Join(mc.outdir, fmt.Sprintf("%s.%s.%s", localeID, label, mc.format)) + if err := ioutil.WriteFile(filename, buf, 0666); err != nil { return fmt.Errorf("failed to write %s because %s", filename, err) } return nil } -func filter(translations map[string]translation.Translation, filter func(translation.Translation) translation.Translation) []translation.Translation { +func filter(translations map[string]translation.Translation, f func(translation.Translation) translation.Translation) []translation.Translation { filtered := make([]translation.Translation, 0, len(translations)) for _, translation := range translations { - if t := filter(translation); t != nil { + if t := f(translation); t != nil { filtered = append(filtered, t) } } @@ -127,21 +132,15 @@ func filter(translations map[string]translation.Translation, filter func(transla } -func newMarshalFunc(format string) (marshalFunc, error) { - switch format { - case "json": - return func(v interface{}) ([]byte, error) { - return json.MarshalIndent(v, "", " ") - }, nil - case "yaml": - return func(v interface{}) ([]byte, error) { - return yaml.Marshal(v) - }, nil +func marshalFlatInterface(translations []translation.Translation) interface{} { + mi := make(map[string]interface{}, len(translations)) + for _, translation := range translations { + mi[translation.ID()] = translation.MarshalFlatInterface() } - return nil, fmt.Errorf("unsupported format: %s\n", format) + return mi } -func marshalInterface(translations []translation.Translation) []interface{} { +func marshalInterface(translations []translation.Translation) interface{} { mi := make([]interface{}, len(translations)) for i, translation := range translations { mi[i] = translation.MarshalInterface() @@ -149,6 +148,16 @@ func marshalInterface(translations []translation.Translation) []interface{} { return mi } +func (mc mergeCommand) marshal(v interface{}) ([]byte, error) { + switch mc.format { + case "json": + return json.MarshalIndent(v, "", " ") + case "yaml": + return yaml.Marshal(v) + } + return nil, fmt.Errorf("unsupported format: %s\n", mc.format) +} + func usageMerge() { fmt.Printf(`Merge translation files. @@ -201,6 +210,9 @@ Options: Supported formats: json, yaml Default: json + -flat + goi18n writes the output translation files in flat format. + Default: true + `) - os.Exit(1) } diff --git a/goi18n/merge_command_flat_test.go b/goi18n/merge_command_flat_test.go new file mode 100644 index 00000000..614785ee --- /dev/null +++ b/goi18n/merge_command_flat_test.go @@ -0,0 +1,36 @@ +package main + +import "testing" + +func TestMergeExecuteFlatJSON(t *testing.T) { + files := []string{ + "testdata/input/flat/en-us.one.json", + "testdata/input/flat/en-us.two.json", + "testdata/input/flat/fr-fr.json", + "testdata/input/flat/ar-ar.one.json", + "testdata/input/flat/ar-ar.two.json", + } + testFlatMergeExecute(t, files) +} + +func testFlatMergeExecute(t *testing.T, files []string) { + resetDir(t, "testdata/output/flat") + + mc := &mergeCommand{ + translationFiles: files, + sourceLanguage: "en-us", + outdir: "testdata/output/flat", + format: "json", + flat: true, + } + if err := mc.execute(); err != nil { + t.Fatal(err) + } + + expectEqualFiles(t, "testdata/output/flat/en-us.all.json", "testdata/expected/flat/en-us.all.json") + expectEqualFiles(t, "testdata/output/flat/ar-ar.all.json", "testdata/expected/flat/ar-ar.all.json") + expectEqualFiles(t, "testdata/output/flat/fr-fr.all.json", "testdata/expected/flat/fr-fr.all.json") + expectEqualFiles(t, "testdata/output/flat/en-us.untranslated.json", "testdata/expected/flat/en-us.untranslated.json") + expectEqualFiles(t, "testdata/output/flat/ar-ar.untranslated.json", "testdata/expected/flat/ar-ar.untranslated.json") + expectEqualFiles(t, "testdata/output/flat/fr-fr.untranslated.json", "testdata/expected/flat/fr-fr.untranslated.json") +} diff --git a/goi18n/merge_command_test.go b/goi18n/merge_command_test.go index 37e46518..425a6b62 100644 --- a/goi18n/merge_command_test.go +++ b/goi18n/merge_command_test.go @@ -37,6 +37,7 @@ func testMergeExecute(t *testing.T, files []string) { sourceLanguage: "en-us", outdir: "testdata/output", format: "json", + flat: false, } if err := mc.execute(); err != nil { t.Fatal(err) @@ -69,6 +70,6 @@ func expectEqualFiles(t *testing.T, expectedName, actualName string) { t.Fatal(err) } if !bytes.Equal(actual, expected) { - t.Fatalf("contents of files did not match: %s, %s", expectedName, actualName) + t.Errorf("contents of files did not match: %s, %s", expectedName, actualName) } } diff --git a/goi18n/testdata/en-us.flat.json b/goi18n/testdata/en-us.flat.json new file mode 100644 index 00000000..f67d21ca --- /dev/null +++ b/goi18n/testdata/en-us.flat.json @@ -0,0 +1,34 @@ +{ + "program_greeting": { + "other": "Hello world" + }, + + "person_greeting": { + "other": "Hello {{.Person}}" + }, + + "my_height_in_meters": { + "one": "I am {{.Count}} meter tall.", + "other": "I am {{.Count}} meters tall." + }, + + "your_unread_email_count": { + "one": "You have {{.Count}} unread email.", + "other": "You have {{.Count}} unread emails." + }, + + "person_unread_email_count": { + "one": "{{.Person}} has {{.Count}} unread email.", + "other": "{{.Person}} has {{.Count}} unread emails." + }, + + "person_unread_email_count_timeframe": { + "one": "{{.Person}} has {{.Count}} unread email in the past {{.Timeframe}}.", + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + + "d_days": { + "one": "{{.Count}} day.", + "other": "{{.Count}} days." + } +} diff --git a/goi18n/testdata/en-us.flat.yaml b/goi18n/testdata/en-us.flat.yaml new file mode 100644 index 00000000..316ac6b2 --- /dev/null +++ b/goi18n/testdata/en-us.flat.yaml @@ -0,0 +1,25 @@ +program_greeting: + other: "Hello world" + +person_greeting: + other: "Hello {{.Person}}" + +my_height_in_meters: + one: "I am {{.Count}} meter tall." + other: "I am {{.Count}} meters tall." + +your_unread_email_count: + one: "You have {{.Count}} unread email." + other: "You have {{.Count}} unread emails." + +person_unread_email_count: + one: "{{.Person}} has {{.Count}} unread email." + other: "{{.Person}} has {{.Count}} unread emails." + +person_unread_email_count_timeframe: + one: "{{.Person}} has {{.Count}} unread email in the past {{.Timeframe}}." + other: "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + +d_days: + one: "{{.Count}} day" + other: "{{.Count}} days" diff --git a/goi18n/testdata/expected/flat/ar-ar.all.json b/goi18n/testdata/expected/flat/ar-ar.all.json new file mode 100644 index 00000000..1adb99ca --- /dev/null +++ b/goi18n/testdata/expected/flat/ar-ar.all.json @@ -0,0 +1,43 @@ +{ + "d_days": { + "few": "new arabic few translation of d_days", + "many": "arabic many translation of d_days", + "one": "arabic one translation of d_days", + "other": "", + "two": "", + "zero": "" + }, + "my_height_in_meters": { + "few": "", + "many": "", + "one": "", + "other": "", + "two": "", + "zero": "" + }, + "person_greeting": { + "other": "new arabic translation of person_greeting" + }, + "person_unread_email_count": { + "few": "arabic few translation of person_unread_email_count", + "many": "arabic many translation of person_unread_email_count", + "one": "arabic one translation of person_unread_email_count", + "other": "arabic other translation of person_unread_email_count", + "two": "arabic two translation of person_unread_email_count", + "zero": "arabic zero translation of person_unread_email_count" + }, + "person_unread_email_count_timeframe": { + "other": "" + }, + "program_greeting": { + "other": "" + }, + "your_unread_email_count": { + "few": "", + "many": "", + "one": "", + "other": "", + "two": "", + "zero": "" + } +} \ No newline at end of file diff --git a/goi18n/testdata/expected/flat/ar-ar.untranslated.json b/goi18n/testdata/expected/flat/ar-ar.untranslated.json new file mode 100644 index 00000000..ea7aa7d1 --- /dev/null +++ b/goi18n/testdata/expected/flat/ar-ar.untranslated.json @@ -0,0 +1,32 @@ +{ + "d_days": { + "few": "new arabic few translation of d_days", + "many": "arabic many translation of d_days", + "one": "arabic one translation of d_days", + "other": "{{.Count}} days", + "two": "{{.Count}} days", + "zero": "{{.Count}} days" + }, + "my_height_in_meters": { + "few": "I am {{.Count}} meters tall.", + "many": "I am {{.Count}} meters tall.", + "one": "I am {{.Count}} meters tall.", + "other": "I am {{.Count}} meters tall.", + "two": "I am {{.Count}} meters tall.", + "zero": "I am {{.Count}} meters tall." + }, + "person_unread_email_count_timeframe": { + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + "program_greeting": { + "other": "Hello world" + }, + "your_unread_email_count": { + "few": "You have {{.Count}} unread emails.", + "many": "You have {{.Count}} unread emails.", + "one": "You have {{.Count}} unread emails.", + "other": "You have {{.Count}} unread emails.", + "two": "You have {{.Count}} unread emails.", + "zero": "You have {{.Count}} unread emails." + } +} \ No newline at end of file diff --git a/goi18n/testdata/expected/flat/en-us.all.json b/goi18n/testdata/expected/flat/en-us.all.json new file mode 100644 index 00000000..766b2a77 --- /dev/null +++ b/goi18n/testdata/expected/flat/en-us.all.json @@ -0,0 +1,27 @@ +{ + "d_days": { + "one": "{{.Count}} day", + "other": "{{.Count}} days" + }, + "my_height_in_meters": { + "one": "I am {{.Count}} meter tall.", + "other": "I am {{.Count}} meters tall." + }, + "person_greeting": { + "other": "Hello {{.Person}}" + }, + "person_unread_email_count": { + "one": "{{.Person}} has {{.Count}} unread email.", + "other": "{{.Person}} has {{.Count}} unread emails." + }, + "person_unread_email_count_timeframe": { + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + "program_greeting": { + "other": "Hello world" + }, + "your_unread_email_count": { + "one": "You have {{.Count}} unread email.", + "other": "You have {{.Count}} unread emails." + } +} \ No newline at end of file diff --git a/goi18n/testdata/expected/flat/en-us.untranslated.json b/goi18n/testdata/expected/flat/en-us.untranslated.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/goi18n/testdata/expected/flat/en-us.untranslated.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/goi18n/testdata/expected/flat/en-us.untranslated.json.json b/goi18n/testdata/expected/flat/en-us.untranslated.json.json new file mode 100644 index 00000000..e69de29b diff --git a/goi18n/testdata/expected/flat/fr-fr.all.json b/goi18n/testdata/expected/flat/fr-fr.all.json new file mode 100644 index 00000000..b0ee0311 --- /dev/null +++ b/goi18n/testdata/expected/flat/fr-fr.all.json @@ -0,0 +1,27 @@ +{ + "d_days": { + "one": "", + "other": "" + }, + "my_height_in_meters": { + "one": "", + "other": "" + }, + "person_greeting": { + "other": "" + }, + "person_unread_email_count": { + "one": "", + "other": "" + }, + "person_unread_email_count_timeframe": { + "other": "" + }, + "program_greeting": { + "other": "" + }, + "your_unread_email_count": { + "one": "", + "other": "" + } +} \ No newline at end of file diff --git a/goi18n/testdata/expected/flat/fr-fr.untranslated.json b/goi18n/testdata/expected/flat/fr-fr.untranslated.json new file mode 100644 index 00000000..e6d5c4fb --- /dev/null +++ b/goi18n/testdata/expected/flat/fr-fr.untranslated.json @@ -0,0 +1,27 @@ +{ + "d_days": { + "one": "{{.Count}} days", + "other": "{{.Count}} days" + }, + "my_height_in_meters": { + "one": "I am {{.Count}} meters tall.", + "other": "I am {{.Count}} meters tall." + }, + "person_greeting": { + "other": "Hello {{.Person}}" + }, + "person_unread_email_count": { + "one": "{{.Person}} has {{.Count}} unread emails.", + "other": "{{.Person}} has {{.Count}} unread emails." + }, + "person_unread_email_count_timeframe": { + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + "program_greeting": { + "other": "Hello world" + }, + "your_unread_email_count": { + "one": "You have {{.Count}} unread emails.", + "other": "You have {{.Count}} unread emails." + } +} \ No newline at end of file diff --git a/goi18n/testdata/input/flat/ar-ar.one.json b/goi18n/testdata/input/flat/ar-ar.one.json new file mode 100644 index 00000000..6ae8d01f --- /dev/null +++ b/goi18n/testdata/input/flat/ar-ar.one.json @@ -0,0 +1,45 @@ +{ + "d_days": { + "few": "arabic few translation of d_days", + "many": "arabic many translation of d_days", + "one": "", + "other": "", + "two": "", + "zero": "" + }, + + "person_greeting": { + "other": "arabic translation of person_greeting" + }, + + "person_unread_email_count": { + "few": "arabic few translation of person_unread_email_count", + "many": "arabic many translation of person_unread_email_count", + "one": "arabic one translation of person_unread_email_count", + "other": "", + "two": "", + "zero": "" + }, + + "person_unread_email_count_timeframe": { + "few": "", + "many": "", + "one": "", + "other": "", + "two": "", + "zero": "" + }, + + "program_greeting": { + "other": "" + }, + + "your_unread_email_count": { + "few": "", + "many": "", + "one": "", + "other": "", + "two": "", + "zero": "" + } +} diff --git a/goi18n/testdata/input/flat/ar-ar.two.json b/goi18n/testdata/input/flat/ar-ar.two.json new file mode 100644 index 00000000..5e6fba41 --- /dev/null +++ b/goi18n/testdata/input/flat/ar-ar.two.json @@ -0,0 +1,45 @@ +{ + "d_days": { + "few": "new arabic few translation of d_days", + "many": "", + "one": "arabic one translation of d_days", + "other": "", + "two": "", + "zero": "" + }, + + "person_greeting": { + "other": "new arabic translation of person_greeting" + }, + + "person_unread_email_count": { + "few": "", + "many": "", + "one": "", + "other": "arabic other translation of person_unread_email_count", + "two": "arabic two translation of person_unread_email_count", + "zero": "arabic zero translation of person_unread_email_count" + }, + + "person_unread_email_count_timeframe": { + "few": "", + "many": "", + "one": "", + "other": "", + "two": "", + "zero": "" + }, + + "program_greeting": { + "other": "" + }, + + "your_unread_email_count": { + "few": "", + "many": "", + "one": "", + "other": "", + "two": "", + "zero": "" + } +} diff --git a/goi18n/testdata/input/flat/en-us.constants.json b/goi18n/testdata/input/flat/en-us.constants.json new file mode 100644 index 00000000..c41b2b97 --- /dev/null +++ b/goi18n/testdata/input/flat/en-us.constants.json @@ -0,0 +1,34 @@ +{ + "d_days": { + "one": "{{.Count}} day", + "other": "{{.Count}} days" + }, + + "my_height_in_meters": { + "one": "I am {{.Count}} meter tall.", + "other": "I am {{.Count}} meters tall." + }, + + "person_greeting": { + "other": "Hello {{.Person}}" + }, + + "person_unread_email_count": { + "one": "{{.Person}} has {{.Count}} unread email.", + "other": "{{.Person}} has {{.Count}} unread emails." + }, + + "person_unread_email_count_timeframe": { + "one": "{{.Person}} has {{.Count}} unread email in the past {{.Timeframe}}.", + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + + "program_greeting": { + "other": "Hello world" + }, + + "your_unread_email_count": { + "one": "You have {{.Count}} unread email.", + "other": "You have {{.Count}} unread emails." + } +} diff --git a/goi18n/testdata/input/flat/en-us.one.json b/goi18n/testdata/input/flat/en-us.one.json new file mode 100644 index 00000000..0d93a893 --- /dev/null +++ b/goi18n/testdata/input/flat/en-us.one.json @@ -0,0 +1,23 @@ +{ + "program_greeting": { + "other": "Hello world" + }, + + "your_unread_email_count": { + "one": "You have {{.Count}} unread email.", + "other": "You have {{.Count}} unread emails." + }, + + "my_height_in_meters": { + "one": "I am {{.Count}} meter tall.", + "other": "I am {{.Count}} meters tall." + }, + + "person_unread_email_count_timeframe": { + "other": "{{.Person}} has {{.Count}} unread email in the past {{.Timeframe}}." + }, + + "d_days": { + "other": "this should get overwritten" + } +} diff --git a/goi18n/testdata/input/flat/en-us.two.json b/goi18n/testdata/input/flat/en-us.two.json new file mode 100644 index 00000000..06bd28dc --- /dev/null +++ b/goi18n/testdata/input/flat/en-us.two.json @@ -0,0 +1,19 @@ +{ + "person_greeting": { + "other": "Hello {{.Person}}" + }, + + "person_unread_email_count": { + "one": "{{.Person}} has {{.Count}} unread email.", + "other": "{{.Person}} has {{.Count}} unread emails." + }, + + "person_unread_email_count_timeframe": { + "other": "{{.Person}} has {{.Count}} unread emails in the past {{.Timeframe}}." + }, + + "d_days": { + "one": "{{.Count}} day", + "other": "{{.Count}} days" + } +} diff --git a/goi18n/testdata/input/flat/fr-fr.json b/goi18n/testdata/input/flat/fr-fr.json new file mode 100644 index 00000000..e69de29b diff --git a/i18n/bundle/bundle.go b/i18n/bundle/bundle.go index 155543bd..e3bc744c 100644 --- a/i18n/bundle/bundle.go +++ b/i18n/bundle/bundle.go @@ -78,34 +78,86 @@ func (b *Bundle) ParseTranslationFileBytes(filename string, buf []byte) error { } func parseTranslations(filename string, buf []byte) ([]translation.Translation, error) { - var unmarshalFunc func([]byte, interface{}) error - switch format := filepath.Ext(filename); format { - case ".json": - unmarshalFunc = json.Unmarshal - case ".yaml": - unmarshalFunc = yaml.Unmarshal - default: - return nil, fmt.Errorf("unsupported file extension %s", format) + if buf == nil || len(buf) == 0 { + return []translation.Translation{}, nil } - var translationsData []map[string]interface{} - if len(buf) > 0 { - if err := unmarshalFunc(buf, &translationsData); err != nil { - return nil, fmt.Errorf("failed to load %s because %s", filename, err) + ext := filepath.Ext(filename) + + if isStandardFormat(ext, buf) { + var standardFormat []map[string]interface{} + if err := unmarshal(ext, buf, &standardFormat); err != nil { + return nil, fmt.Errorf("failed to unmarshal %v: %v", filename, err) } + return parseStandardFormat(standardFormat) + } else { + var flatFormat map[string]map[string]interface{} + if err := unmarshal(ext, buf, &flatFormat); err != nil { + return nil, fmt.Errorf("failed to unmarshal %v: %v", filename, err) + } + return parseFlatFormat(flatFormat) + } +} + +func isStandardFormat(ext string, buf []byte) bool { + firstRune := rune(buf[0]) + if (ext == ".json" && firstRune == '[') || (ext == ".yaml" && firstRune == '-') { + return true + } + return false +} + +// unmarshal finds an appropriate unmarshal function for ext +// (extension of filename) and unmarshals buf to out. out must be a pointer. +func unmarshal(ext string, buf []byte, out interface{}) error { + switch ext { + case ".json": + return json.Unmarshal(buf, out) + case ".yaml": + return yaml.Unmarshal(buf, out) + default: + return fmt.Errorf("unsupported file extension %v", ext) } +} - translations := make([]translation.Translation, 0, len(translationsData)) - for i, translationData := range translationsData { +func parseStandardFormat(data []map[string]interface{}) ([]translation.Translation, error) { + translations := make([]translation.Translation, 0, len(data)) + for i, translationData := range data { t, err := translation.NewTranslation(translationData) if err != nil { - return nil, fmt.Errorf("unable to parse translation #%d in %s because %s\n%v", i, filename, err, translationData) + return nil, fmt.Errorf("unable to parse translation #%d because %s\n%v", i, err, translationData) } translations = append(translations, t) } return translations, nil } +// parseFlatFormat just converts data from flat format to standard format +// and passes it to parseStandardFormat. +// +// Flat format logic: +// key of data must be a string and data[key] must be always map[string]interface{}, +// but if there is only "other" key in it then it is non-plural, else plural. +func parseFlatFormat(data map[string]map[string]interface{}) ([]translation.Translation, error) { + var standardFormatData []map[string]interface{} + for id, translationData := range data { + dataObject := make(map[string]interface{}) + dataObject["id"] = id + if len(translationData) == 1 { // non-plural form + _, otherExists := translationData["other"] + if otherExists { + dataObject["translation"] = translationData["other"] + } + } else { // plural form + dataObject["translation"] = translationData + } + + standardFormatData = append(standardFormatData, dataObject) + } + + return parseStandardFormat(standardFormatData) +} + // AddTranslation adds translations for a language. // // It is useful if your translations are in a format not supported by LoadTranslationFile. diff --git a/i18n/translation/plural_translation.go b/i18n/translation/plural_translation.go index 4f579d16..5dd74b2f 100644 --- a/i18n/translation/plural_translation.go +++ b/i18n/translation/plural_translation.go @@ -16,6 +16,10 @@ func (pt *pluralTranslation) MarshalInterface() interface{} { } } +func (pt *pluralTranslation) MarshalFlatInterface() interface{} { + return pt.templates +} + func (pt *pluralTranslation) ID() string { return pt.id } diff --git a/i18n/translation/single_translation.go b/i18n/translation/single_translation.go index 1010e594..9fcba5a1 100644 --- a/i18n/translation/single_translation.go +++ b/i18n/translation/single_translation.go @@ -16,6 +16,10 @@ func (st *singleTranslation) MarshalInterface() interface{} { } } +func (st *singleTranslation) MarshalFlatInterface() interface{} { + return map[string]interface{}{"other": st.template} +} + func (st *singleTranslation) ID() string { return st.id } diff --git a/i18n/translation/template.go b/i18n/translation/template.go index c8756fa4..3310150c 100644 --- a/i18n/translation/template.go +++ b/i18n/translation/template.go @@ -13,6 +13,10 @@ type template struct { } func newTemplate(src string) (*template, error) { + if src == "" { + return new(template), nil + } + var tmpl template err := tmpl.parseTemplate(src) return &tmpl, err diff --git a/i18n/translation/translation.go b/i18n/translation/translation.go index fa93180b..19751462 100644 --- a/i18n/translation/translation.go +++ b/i18n/translation/translation.go @@ -12,6 +12,7 @@ type Translation interface { // MarshalInterface returns the object that should be used // to serialize the translation. MarshalInterface() interface{} + MarshalFlatInterface() interface{} ID() string Template(language.Plural) *template UntranslatedCopy() Translation diff --git a/i18n/translations_test.go b/i18n/translations_test.go new file mode 100644 index 00000000..70c369b8 --- /dev/null +++ b/i18n/translations_test.go @@ -0,0 +1,85 @@ +package i18n + +import ( + "testing" + + "github.com/nicksnyder/go-i18n/i18n/bundle" +) + +var bobMap = map[string]interface{}{"Person": "Bob"} +var bobStruct = struct{ Person string }{Person: "Bob"} + +var testCases = []struct { + id string + arg interface{} + want string +}{ + {"program_greeting", nil, "Hello world"}, + {"person_greeting", bobMap, "Hello Bob"}, + {"person_greeting", bobStruct, "Hello Bob"}, + + {"your_unread_email_count", 0, "You have 0 unread emails."}, + {"your_unread_email_count", 1, "You have 1 unread email."}, + {"your_unread_email_count", 2, "You have 2 unread emails."}, + {"my_height_in_meters", "1.7", "I am 1.7 meters tall."}, + + {"person_unread_email_count", []interface{}{0, bobMap}, "Bob has 0 unread emails."}, + {"person_unread_email_count", []interface{}{1, bobMap}, "Bob has 1 unread email."}, + {"person_unread_email_count", []interface{}{2, bobMap}, "Bob has 2 unread emails."}, + {"person_unread_email_count", []interface{}{0, bobStruct}, "Bob has 0 unread emails."}, + {"person_unread_email_count", []interface{}{1, bobStruct}, "Bob has 1 unread email."}, + {"person_unread_email_count", []interface{}{2, bobStruct}, "Bob has 2 unread emails."}, + + {"person_unread_email_count_timeframe", []interface{}{3, map[string]interface{}{ + "Person": "Bob", + "Timeframe": "0 days", + }}, "Bob has 3 unread emails in the past 0 days."}, + {"person_unread_email_count_timeframe", []interface{}{3, map[string]interface{}{ + "Person": "Bob", + "Timeframe": "1 day", + }}, "Bob has 3 unread emails in the past 1 day."}, + {"person_unread_email_count_timeframe", []interface{}{3, map[string]interface{}{ + "Person": "Bob", + "Timeframe": "2 days", + }}, "Bob has 3 unread emails in the past 2 days."}, +} + +func testFile(t *testing.T, path string) { + b := bundle.New() + b.MustLoadTranslationFile(path) + + T, err := b.Tfunc("en-US") + if err != nil { + t.Fatal(err) + } + + for _, tc := range testCases { + var args []interface{} + if _, ok := tc.arg.([]interface{}); ok { + args = tc.arg.([]interface{}) + } else { + args = []interface{}{tc.arg} + } + + got := T(tc.id, args...) + if got != tc.want { + t.Error("got: %v; want: %v", got, tc.want) + } + } +} + +func TestJSONParse(t *testing.T) { + testFile(t, "../goi18n/testdata/expected/en-us.all.json") +} + +func TestYAMLParse(t *testing.T) { + testFile(t, "../goi18n/testdata/en-us.yaml") +} + +func TestJSONFlatParse(t *testing.T) { + testFile(t, "../goi18n/testdata/en-us.flat.json") +} + +func TestYAMLFlatParse(t *testing.T) { + testFile(t, "../goi18n/testdata/en-us.flat.yaml") +}