// Package bundle manages translations for multiple languages. package bundle import ( "encoding/json" "fmt" "gopkg.in/yaml.v2" "io/ioutil" "reflect" "path/filepath" "github.com/nicksnyder/go-i18n/i18n/language" "github.com/nicksnyder/go-i18n/i18n/translation" ) // TranslateFunc is a copy of i18n.TranslateFunc to avoid a circular dependency. type TranslateFunc func(translationID string, args ...interface{}) string // Bundle stores the translations for multiple languages. type Bundle struct { // The primary translations for a language tag and translation id. translations map[string]map[string]translation.Translation // Translations that can be used when an exact language match is not possible. fallbackTranslations map[string]map[string]translation.Translation } // New returns an empty bundle. func New() *Bundle { return &Bundle{ translations: make(map[string]map[string]translation.Translation), fallbackTranslations: make(map[string]map[string]translation.Translation), } } // MustLoadTranslationFile is similar to LoadTranslationFile // except it panics if an error happens. func (b *Bundle) MustLoadTranslationFile(filename string) { if err := b.LoadTranslationFile(filename); err != nil { panic(err) } } // LoadTranslationFile loads the translations from filename into memory. // // The language that the translations are associated with is parsed from the filename (e.g. en-US.json). // // Generally you should load translation files once during your program's initialization. func (b *Bundle) LoadTranslationFile(filename string) error { buf, err := ioutil.ReadFile(filename) if err != nil { return err } return b.ParseTranslationFileBytes(filename, buf) } // ParseTranslationFileBytes is similar to LoadTranslationFile except it parses the bytes in buf. // // It is useful for parsing translation files embedded with go-bindata. func (b *Bundle) ParseTranslationFileBytes(filename string, buf []byte) error { basename := filepath.Base(filename) langs := language.Parse(basename) switch l := len(langs); { case l == 0: return fmt.Errorf("no language found in %q", basename) case l > 1: return fmt.Errorf("multiple languages found in filename %q: %v; expected one", basename, langs) } translations, err := parseTranslations(filename, buf) if err != nil { return err } b.AddTranslation(langs[0], translations...) return nil } 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) } var translationsData []map[string]interface{} if len(buf) > 0 { if err := unmarshalFunc(buf, &translationsData); err != nil { return nil, err } } translations := make([]translation.Translation, 0, len(translationsData)) for i, translationData := range translationsData { 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) } translations = append(translations, t) } return translations, nil } // AddTranslation adds translations for a language. // // It is useful if your translations are in a format not supported by LoadTranslationFile. func (b *Bundle) AddTranslation(lang *language.Language, translations ...translation.Translation) { if b.translations[lang.Tag] == nil { b.translations[lang.Tag] = make(map[string]translation.Translation, len(translations)) } currentTranslations := b.translations[lang.Tag] for _, newTranslation := range translations { if currentTranslation := currentTranslations[newTranslation.ID()]; currentTranslation != nil { currentTranslations[newTranslation.ID()] = currentTranslation.Merge(newTranslation) } else { currentTranslations[newTranslation.ID()] = newTranslation } } // lang can provide translations for less specific language tags. for _, tag := range lang.MatchingTags() { b.fallbackTranslations[tag] = currentTranslations } } // Translations returns all translations in the bundle. func (b *Bundle) Translations() map[string]map[string]translation.Translation { return b.translations } // LanguageTags returns the tags of all languages that that have been added. func (b *Bundle) LanguageTags() []string { var tags []string for k := range b.translations { tags = append(tags, k) } return tags } // LanguageTranslationIDs returns the ids of all translations that have been added for a given language. func (b *Bundle) LanguageTranslationIDs(languageTag string) []string { var ids []string for id := range b.translations[languageTag] { ids = append(ids, id) } return ids } // MustTfunc is similar to Tfunc except it panics if an error happens. func (b *Bundle) MustTfunc(pref string, prefs ...string) TranslateFunc { tfunc, err := b.Tfunc(pref, prefs...) if err != nil { panic(err) } return tfunc } // MustTfuncAndLanguage is similar to TfuncAndLanguage except it panics if an error happens. func (b *Bundle) MustTfuncAndLanguage(pref string, prefs ...string) (TranslateFunc, *language.Language) { tfunc, language, err := b.TfuncAndLanguage(pref, prefs...) if err != nil { panic(err) } return tfunc, language } // Tfunc is similar to TfuncAndLanguage except is doesn't return the Language. func (b *Bundle) Tfunc(pref string, prefs ...string) (TranslateFunc, error) { tfunc, _, err := b.TfuncAndLanguage(pref, prefs...) return tfunc, err } // TfuncAndLanguage returns a TranslateFunc for the first Language that // has a non-zero number of translations in the bundle. // // The returned Language matches the the first language preference that could be satisfied, // but this may not strictly match the language of the translations used to satisfy that preference. // // For example, the user may request "zh". If there are no translations for "zh" but there are translations // for "zh-cn", then the translations for "zh-cn" will be used but the returned Language will be "zh". // // It can parse languages from Accept-Language headers (RFC 2616), // but it assumes weights are monotonically decreasing. func (b *Bundle) TfuncAndLanguage(pref string, prefs ...string) (TranslateFunc, *language.Language, error) { lang := b.supportedLanguage(pref, prefs...) var err error if lang == nil { err = fmt.Errorf("no supported languages found %#v", append(prefs, pref)) } return func(translationID string, args ...interface{}) string { return b.translate(lang, translationID, args...) }, lang, err } // supportedLanguage returns the first language which // has a non-zero number of translations in the bundle. func (b *Bundle) supportedLanguage(pref string, prefs ...string) *language.Language { lang := b.translatedLanguage(pref) if lang == nil { for _, pref := range prefs { lang = b.translatedLanguage(pref) if lang != nil { break } } } return lang } func (b *Bundle) translatedLanguage(src string) *language.Language { langs := language.Parse(src) for _, lang := range langs { if len(b.translations[lang.Tag]) > 0 || len(b.fallbackTranslations[lang.Tag]) > 0 { return lang } } return nil } func (b *Bundle) translate(lang *language.Language, translationID string, args ...interface{}) string { if lang == nil { return translationID } translations := b.translations[lang.Tag] if translations == nil { translations = b.fallbackTranslations[lang.Tag] if translations == nil { return translationID } } translation := translations[translationID] if translation == nil { return translationID } var data interface{} var count interface{} if argc := len(args); argc > 0 { if isNumber(args[0]) { count = args[0] if argc > 1 { data = args[1] } } else { data = args[0] } } if count != nil { if data == nil { data = map[string]interface{}{"Count": count} } else { dataMap := toMap(data) dataMap["Count"] = count data = dataMap } } else { dataMap := toMap(data) if c, ok := dataMap["Count"]; ok { count = c } } p, _ := lang.Plural(count) template := translation.Template(p) if template == nil { return translationID } s := template.Execute(data) if s == "" { return translationID } return s } func isNumber(n interface{}) bool { switch n.(type) { case int, int8, int16, int32, int64, string: return true } return false } func toMap(input interface{}) map[string]interface{} { if data, ok := input.(map[string]interface{}); ok { return data } v := reflect.ValueOf(input) switch v.Kind() { case reflect.Ptr: return toMap(v.Elem().Interface()) case reflect.Struct: return structToMap(v) default: return nil } } // Converts the top level of a struct to a map[string]interface{}. // Code inspired by github.com/fatih/structs. func structToMap(v reflect.Value) map[string]interface{} { out := make(map[string]interface{}) t := v.Type() for i := 0; i < t.NumField(); i++ { field := t.Field(i) if field.PkgPath != "" { // unexported field. skip. continue } out[field.Name] = v.FieldByName(field.Name).Interface() } return out }