Browse Source

Merge pull request #12 from go-playground/file-import-export

Add File Import/Export logic
Dean Karn 9 years ago
parent
commit
5529dd1924

+ 1 - 1
.gitignore

@@ -21,4 +21,4 @@ _testmain.go
 
 *.exe
 *.test
-*.prof
+*.prof

+ 1 - 1
LICENSE

@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2016 Go Experimental
+Copyright (c) 2016 Go Playground
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal

+ 34 - 68
README.md

@@ -1,6 +1,6 @@
 ## universal-translator
 <img align="right" src="https://raw.githubusercontent.com/go-playground/universal-translator/master/logo.png">
-![Project status](https://img.shields.io/badge/version-0.14.0-green.svg)
+![Project status](https://img.shields.io/badge/version-0.15.0-green.svg)
 [![Build Status](https://semaphoreci.com/api/v1/joeybloggs/universal-translator/branches/master/badge.svg)](https://semaphoreci.com/joeybloggs/universal-translator)
 [![Coverage Status](https://coveralls.io/repos/github/go-playground/universal-translator/badge.svg)](https://coveralls.io/github/go-playground/universal-translator)
 [![Go Report Card](https://goreportcard.com/badge/github.com/go-playground/universal-translator)](https://goreportcard.com/report/github.com/go-playground/universal-translator)
@@ -25,8 +25,8 @@ Features
 - [x] Contains Date & Time formatting functions
 - [x] Contains Number, Currency, Accounting and Percent formatting functions
 - [x] Supports the "Gregorian" calendar only ( my time isn't unlimited, had to draw the line somewhere )
-- [ ] Support loading translations from files
-- [ ] Exporting translations to file, mainly for getting them professionally translated
+- [x] Support loading translations from files
+- [x] Exporting translations to file(s), mainly for getting them professionally translated
 - [ ] Code Generation for translation files -> Go code.. i.e. after it has been professionally translated
 - [ ] Tests for all languages, I need help with this, please see [here](https://github.com/go-playground/locales/issues/1)
 
@@ -35,84 +35,50 @@ Installation
 
 Use go get 
 
-```go
+```shell
 go get github.com/go-playground/universal-translator
 ```
 
-Usage
+Usage & Documentation
 -------
-```go
-package main
 
-import (
-	"fmt"
+Please see https://godoc.org/github.com/go-playground/universal-translator for usage docs
 
-	"github.com/go-playground/locales"
-	"github.com/go-playground/locales/en"
-	"github.com/go-playground/locales/en_CA"
-	"github.com/go-playground/locales/fr"
-	"github.com/go-playground/locales/nl"
-	"github.com/go-playground/universal-translator"
-)
+##### Examples:
 
-// only one instance as translators within are shared + goroutine safe
-var universalTraslator *ut.UniversalTranslator
+- [Basic](https://github.com/go-playground/universal-translator/tree/master/examples/basic)
+- [Full - no files](https://github.com/go-playground/universal-translator/tree/master/examples/full-no-files)
+- [Full - with files](https://github.com/go-playground/universal-translator/tree/master/examples/full-with-files)
 
-func main() {
+File formatting
+--------------
+All types, Plain substitution, Cardinal, Ordinal and Range translations can all be contained withing the same file(s);
+they are only separated for easy viewing.
 
-	// NOTE: this example is omitting a lot of error checking for brevity
-	e := en.New()
-	universalTraslator = ut.New(e, e, en_CA.New(), nl.New(), fr.New())
+##### Examples:
 
-	en, _ := universalTraslator.GetTranslator("en")
+- [Formats](https://github.com/go-playground/universal-translator/tree/master/examples/file-formats)
 
-	// generally used after parsing an http 'Accept-Language' header
-	// and this will try to find a matching locale you support or
-	// fallback locale.
-	// en, _ := ut.FindTranslator([]string{"en", "en_CA", "nl"})
-
-	// this will help
-	fmt.Println("Cardinal Plural Rules:", en.PluralsCardinal())
-	fmt.Println("Ordinal Plural Rules:", en.PluralsOrdinal())
-	fmt.Println("Range Plural Rules:", en.PluralsRange())
-
-	// add basic language only translations
-	// last param indicates if it's ok to override the translation if one already exists
-	en.Add("welcome", "Welcome {0} to our test", false)
-
-	// add language translations dependant on cardinal plural rules
-	en.AddCardinal("days", "You have {0} day left to register", locales.PluralRuleOne, false)
-	en.AddCardinal("days", "You have {0} days left to register", locales.PluralRuleOther, false)
-
-	// add language translations dependant on ordinal plural rules
-	en.AddOrdinal("day-of-month", "{0}st", locales.PluralRuleOne, false)
-	en.AddOrdinal("day-of-month", "{0}nd", locales.PluralRuleTwo, false)
-	en.AddOrdinal("day-of-month", "{0}rd", locales.PluralRuleFew, false)
-	en.AddOrdinal("day-of-month", "{0}th", locales.PluralRuleOther, false)
-
-	// add language translations dependant on range plural rules
-	// NOTE: only one plural rule for range in 'en' locale
-	en.AddRange("between", "It's {0}-{1} days away", locales.PluralRuleOther, false)
-
-	// now lets use the translations we just added, in the same order we added them
-
-	fmt.Println(en.T("welcome", "Joeybloggs"))
-
-	fmt.Println(en.C("days", 1, 0, en.FmtNumber(1, 0))) // you'd normally have variables defined for 1 and 0
-	fmt.Println(en.C("days", 2, 0, en.FmtNumber(2, 0)))
-	fmt.Println(en.C("days", 10456.25, 2, en.FmtNumber(10456.25, 2)))
-
-	fmt.Println(en.O("day-of-month", 1, 0, en.FmtNumber(1, 0)))
-	fmt.Println(en.O("day-of-month", 2, 0, en.FmtNumber(2, 0)))
-	fmt.Println(en.O("day-of-month", 3, 0, en.FmtNumber(3, 0)))
-	fmt.Println(en.O("day-of-month", 4, 0, en.FmtNumber(4, 0)))
-	fmt.Println(en.O("day-of-month", 10456.25, 0, en.FmtNumber(10456.25, 0)))
-
-	fmt.Println(en.R("between", 0, 0, 1, 0, en.FmtNumber(0, 0), en.FmtNumber(1, 0)))
-	fmt.Println(en.R("between", 1, 0, 2, 0, en.FmtNumber(1, 0), en.FmtNumber(2, 0)))
-	fmt.Println(en.R("between", 1, 0, 100, 0, en.FmtNumber(1, 0), en.FmtNumber(100, 0)))
+##### Basic Makeup
+NOTE: not all fields are needed for all translation types, see [examples](https://github.com/go-playground/universal-translator/tree/master/examples/file-formats)
+```json
+{
+    "locale": "en",
+    "key": "days-left",
+    "trans": "You have {0} day left.",
+    "type": "Cardinal",
+    "rule": "One",
+    "override": false
 }
 ```
+|Field|Description|
+|---|---|
+|locale|The locale for which the translation is for.|
+|key|The translation key that will be used to store and lookup each translation; normally it is a string or integer.|
+|trans|The actual translation text.|
+|type|The type of translation Cardinal, Ordinal, Range or "" for a plain substitution(not required to be defined if plain used)|
+|rule|The plural rule for which the translation is for eg. One, Two, Few, Many or Other.(not required to be defined if plain used)|
+|override|If you wish to override an existing translation that has already been registered, set this to 'true'. 99% of the time there is no need to define it.|
 
 Help With Tests
 ---------------

+ 60 - 7
errors.go

@@ -31,19 +31,20 @@ func (e *ErrExistingTranslator) Error() string {
 
 // ErrConflictingTranslation is the error representing a conflicting translation
 type ErrConflictingTranslation struct {
-	key  interface{}
-	rule locales.PluralRule
-	text string
+	locale string
+	key    interface{}
+	rule   locales.PluralRule
+	text   string
 }
 
 // Error returns ErrConflictingTranslation's internal error text
 func (e *ErrConflictingTranslation) Error() string {
 
 	if _, ok := e.key.(string); !ok {
-		return fmt.Sprintf("error: conflicting key '%#v' rule '%s' with text '%s', value being ignored", e.key, e.rule, e.text)
+		return fmt.Sprintf("error: conflicting key '%#v' rule '%s' with text '%s' for locale '%s', value being ignored", e.key, e.rule, e.text, e.locale)
 	}
 
-	return fmt.Sprintf("error: conflicting key '%s' rule '%s' with text '%s', value being ignored", e.key, e.rule, e.text)
+	return fmt.Sprintf("error: conflicting key '%s' rule '%s' with text '%s' for locale '%s', value being ignored", e.key, e.rule, e.text, e.locale)
 }
 
 // ErrRangeTranslation is the error representing a range translation error
@@ -79,6 +80,7 @@ func (e *ErrCardinalTranslation) Error() string {
 // ErrMissingPluralTranslation is the error signifying a missing translation given
 // the locales plural rules.
 type ErrMissingPluralTranslation struct {
+	locale          string
 	key             interface{}
 	rule            locales.PluralRule
 	translationType string
@@ -88,8 +90,59 @@ type ErrMissingPluralTranslation struct {
 func (e *ErrMissingPluralTranslation) Error() string {
 
 	if _, ok := e.key.(string); !ok {
-		return fmt.Sprintf("error: missing '%s' plural rule '%s' for translation with key '%#v'", e.translationType, e.rule, e.key)
+		return fmt.Sprintf("error: missing '%s' plural rule '%s' for translation with key '%#v' and locale '%s'", e.translationType, e.rule, e.key, e.locale)
 	}
 
-	return fmt.Sprintf("error: missing '%s' plural rule '%s' for translation with key '%s'", e.translationType, e.rule, e.key)
+	return fmt.Sprintf("error: missing '%s' plural rule '%s' for translation with key '%s' and locale '%s'", e.translationType, e.rule, e.key, e.locale)
+}
+
+// ErrMissingBracket is the error representing a missing bracket in a translation
+// eg. This is a {0 <-- missing ending '}'
+type ErrMissingBracket struct {
+	locale string
+	key    interface{}
+	text   string
+}
+
+// Error returns ErrMissingBracket error message
+func (e *ErrMissingBracket) Error() string {
+	return fmt.Sprintf("error: missing bracket '{}', in translation. locale: '%s' key: '%v' text: '%s'", e.locale, e.key, e.text)
+}
+
+// ErrBadParamSyntax is the error representing a bad parameter definition in a translation
+// eg. This is a {must-be-int}
+type ErrBadParamSyntax struct {
+	locale string
+	param  string
+	key    interface{}
+	text   string
+}
+
+// Error returns ErrBadParamSyntax error message
+func (e *ErrBadParamSyntax) Error() string {
+	return fmt.Sprintf("error: bad parameter syntax, missing parameter '%s' in translation. locale: '%s' key: '%v' text: '%s'", e.param, e.locale, e.key, e.text)
+}
+
+// import/export errors
+
+// ErrMissingLocale is the error representing an expected locale that could
+// not be found aka locale not registered with the UniversalTranslator Instance
+type ErrMissingLocale struct {
+	locale string
+}
+
+// Error returns ErrMissingLocale's internal error text
+func (e *ErrMissingLocale) Error() string {
+	return fmt.Sprintf("error: locale '%s' not registered.", e.locale)
+}
+
+// ErrBadPluralDefinition is the error representing an incorrect plural definition
+// usually found within translations defined within files during the import process.
+type ErrBadPluralDefinition struct {
+	tl translation
+}
+
+// Error returns ErrBadPluralDefinition's internal error text
+func (e *ErrBadPluralDefinition) Error() string {
+	return fmt.Sprintf("error: bad plural definition '%#v'", e.tl)
 }

+ 16 - 0
examples/file-formats/cardinal.json

@@ -0,0 +1,16 @@
+[
+    {
+        "locale": "en",
+        "key": "cardinal_test",
+        "trans": "You have {0} day left.",
+        "type": "Cardinal",
+        "rule": "One"
+    },
+    {
+        "locale": "en",
+        "key": "cardinal_test",
+        "trans": "You have {0} days left.",
+        "type": "Cardinal",
+        "rule": "Other"
+    }
+]

+ 30 - 0
examples/file-formats/ordinal.json

@@ -0,0 +1,30 @@
+[
+    {
+        "locale": "en",
+        "key": "day",
+        "trans": "{0}st",
+        "type": "Ordinal",
+        "rule": "One"
+    },
+    {
+        "locale": "en",
+        "key": "day",
+        "trans": "{0}nd",
+        "type": "Ordinal",
+        "rule": "Two"
+    },
+    {
+        "locale": "en",
+        "key": "day",
+        "trans": "{0}rd",
+        "type": "Ordinal",
+        "rule": "Few"
+    },
+    {
+        "locale": "en",
+        "key": "day",
+        "trans": "{0}th",
+        "type": "Ordinal",
+        "rule": "Other"
+    }
+]

+ 27 - 0
examples/file-formats/plain-substitution.json

@@ -0,0 +1,27 @@
+[
+    {
+        "locale": "en",
+        "key": "test_trans4",
+        "trans": "{0}{1}"
+    },
+    {
+        "locale": "en",
+        "key": "test_trans",
+        "trans": "Welcome {0} to the {1}."
+    },
+    {
+        "locale": "en",
+        "key": -1,
+        "trans": "Welcome {0}"
+    },
+    {
+        "locale": "en",
+        "key": "test_trans2",
+        "trans": "{0} to the {1}."
+    },
+    {
+        "locale": "en",
+        "key": "test_trans3",
+        "trans": "Welcome {0} to the {1}"
+    }
+]

+ 16 - 0
examples/file-formats/range.json

@@ -0,0 +1,16 @@
+[
+    {
+        "locale": "nl",
+        "key": "day",
+        "trans": "er {0}-{1} dag vertrokken",
+        "type": "Range",
+        "rule": "One"
+    },
+    {
+        "locale": "nl",
+        "key": "day",
+        "trans": "er zijn {0}-{1} dagen over",
+        "type": "Range",
+        "rule": "Other"
+    }
+]

+ 35 - 0
examples/full-no-files/home.tmpl

@@ -0,0 +1,35 @@
+{{ define "home" }}
+<!DOCTYPE html>
+<html>
+	<head>
+		<title>Home</title>
+	</head>
+	<body>
+		<p>Locale: {{ .Trans.Locale }}</p>
+        <p>Trans1: {{ .Trans.C "days-left" 1 0 "1" }}</p>
+        <p>Trans2: {{ .Trans.C "days-left" 2 0 "2" }}</p>
+        <p>FmtNumber Positive: {{ .Trans.FmtNumber .PositiveNum 2 }}</p>
+        <p>FmtNumber Negative: {{ .Trans.FmtNumber .NegativeNum 2 }}</p>
+        <p>FmtPercent Negative: {{ .Trans.FmtPercent .Percent 2 }}</p>
+        <p>FmtCurrency Negative: {{ .Trans.FmtCurrency .PositiveNum 2 .Trans.Currency }}</p>
+        <p>FmtCurrency Negative: {{ .Trans.FmtCurrency .NegativeNum 2 .Trans.Currency }}</p>
+        <p>FmtAccounting Negative: {{ .Trans.FmtAccounting .PositiveNum 2 .Trans.Currency }}</p>
+        <p>FmtAccounting Negative: {{ .Trans.FmtAccounting .NegativeNum 2 .Trans.Currency }}</p>
+        <p>FmtDateShort: {{ .Trans.FmtDateShort .Now }}</p>
+        <p>FmtDateMedium: {{ .Trans.FmtDateMedium .Now }}</p>
+        <p>FmtDateLong: {{ .Trans.FmtDateLong .Now }}</p>
+        <p>FmtDateFull: {{ .Trans.FmtDateFull .Now }}</p>
+        <p>FmtTimeShort: {{ .Trans.FmtTimeShort .Now }}</p>
+        <p>FmtTimeMedium: {{ .Trans.FmtTimeMedium .Now }}</p>
+        <p>FmtTimeLong: {{ .Trans.FmtTimeLong .Now }}</p>
+        <p>FmtTimeFull: {{ .Trans.FmtTimeFull .Now }}</p>
+        <p>MonthsAbbreviated: {{ .Trans.MonthsAbbreviated }}</p>
+        <p>MonthsNarrow: {{ .Trans.MonthsNarrow }}</p>
+        <p>MonthsWide: {{ .Trans.MonthsWide }}</p>
+        <p>WeekdaysAbbreviated: {{ .Trans.WeekdaysAbbreviated }}</p>
+        <p>WeekdaysNarrow: {{ .Trans.WeekdaysNarrow }}</p>
+        <p>WeekdaysShort: {{ .Trans.WeekdaysShort }}</p>
+        <p>WeekdaysWide: {{ .Trans.WeekdaysWide }}</p>
+	</body>
+</html>
+{{ end }}

+ 210 - 0
examples/full-no-files/main.go

@@ -0,0 +1,210 @@
+package main
+
+import (
+	"context"
+	"html/template"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/go-playground/locales"
+	"github.com/go-playground/locales/currency"
+	"github.com/go-playground/locales/en"
+	"github.com/go-playground/locales/fr"
+	"github.com/go-playground/pure"
+	"github.com/go-playground/pure/examples/middleware/logging-recovery"
+	"github.com/go-playground/universal-translator"
+)
+
+var (
+	tmpls    *template.Template
+	utrans   *ut.UniversalTranslator
+	transKey = struct {
+		name string
+	}{
+		name: "transKey",
+	}
+)
+
+// Translator wraps ut.Translator in order to handle errors transparently
+// it is totally optional but recommended as it can now be used directly in
+// templates and nobody can add translations where they're not supposed to.
+type Translator interface {
+	locales.Translator
+
+	// creates the translation for the locale given the 'key' and params passed in.
+	// wraps ut.Translator.T to handle errors
+	T(key interface{}, params ...string) string
+
+	// creates the cardinal translation for the locale given the 'key', 'num' and 'digit' arguments
+	//  and param passed in.
+	// wraps ut.Translator.C to handle errors
+	C(key interface{}, num float64, digits uint64, param string) string
+
+	// creates the ordinal translation for the locale given the 'key', 'num' and 'digit' arguments
+	// and param passed in.
+	// wraps ut.Translator.O to handle errors
+	O(key interface{}, num float64, digits uint64, param string) string
+
+	//  creates the range translation for the locale given the 'key', 'num1', 'digit1', 'num2' and
+	//  'digit2' arguments and 'param1' and 'param2' passed in
+	// wraps ut.Translator.R to handle errors
+	R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string
+
+	// Currency returns the type used by the given locale.
+	Currency() currency.Type
+}
+
+// implements Translator interface definition above.
+type translator struct {
+	locales.Translator
+	trans ut.Translator
+}
+
+var _ Translator = (*translator)(nil)
+
+func (t *translator) T(key interface{}, params ...string) string {
+
+	s, err := t.trans.T(key, params...)
+	if err != nil {
+		log.Printf("issue translating key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) C(key interface{}, num float64, digits uint64, param string) string {
+
+	s, err := t.trans.C(key, num, digits, param)
+	if err != nil {
+		log.Printf("issue translating cardinal key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) O(key interface{}, num float64, digits uint64, param string) string {
+
+	s, err := t.trans.C(key, num, digits, param)
+	if err != nil {
+		log.Printf("issue translating ordinal key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string {
+
+	s, err := t.trans.R(key, num1, digits1, num2, digits2, param1, param2)
+	if err != nil {
+		log.Printf("issue translating range key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) Currency() currency.Type {
+
+	// choose your own locale. The reason it isn't mapped for you is because many
+	// countries have multiple currencies; it's up to you and you're application how
+	// and which currencies to use. I recommend adding a function it to to your custon translator
+	// interface like defined above.
+	switch t.Locale() {
+	case "en":
+		return currency.USD
+	case "fr":
+		return currency.EUR
+	default:
+		return currency.USD
+	}
+}
+
+func main() {
+
+	en := en.New()
+	utrans = ut.New(en, en, fr.New())
+	setup()
+
+	tmpls, _ = template.ParseFiles("home.tmpl")
+
+	r := pure.New()
+	r.Use(middleware.LoggingAndRecovery(true), translatorMiddleware)
+	r.Get("/", home)
+
+	log.Println("Running on Port :8080")
+	log.Println("Try me with URL http://localhost:8080/?locale=en")
+	log.Println("and then http://localhost:8080/?locale=fr")
+	http.ListenAndServe(":8080", r.Serve())
+}
+
+func home(w http.ResponseWriter, r *http.Request) {
+
+	// get locale translator ( could be wrapped into a helper function )
+	t := r.Context().Value(transKey).(Translator)
+
+	s := struct {
+		Trans       Translator
+		Now         time.Time
+		PositiveNum float64
+		NegativeNum float64
+		Percent     float64
+	}{
+		Trans:       t,
+		Now:         time.Now(),
+		PositiveNum: 1234576.45,
+		NegativeNum: -35900394.34,
+		Percent:     96.76,
+	}
+
+	if err := tmpls.ExecuteTemplate(w, "home", s); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func translatorMiddleware(next http.HandlerFunc) http.HandlerFunc {
+
+	return func(w http.ResponseWriter, r *http.Request) {
+
+		// there are many ways to check, this is just checking for query param &
+		// Accept-Language header but can be expanded to Cookie's etc....
+
+		params := r.URL.Query()
+
+		locale := params.Get("locale")
+		var t ut.Translator
+
+		if len(locale) > 0 {
+
+			var found bool
+
+			if t, found = utrans.GetTranslator(locale); found {
+				goto END
+			}
+		}
+
+		// get and parse the "Accept-Language" http header and return an array
+		t, _ = utrans.FindTranslator(pure.AcceptedLanguages(r)...)
+	END:
+		// I would normally wrap ut.Translator with one with my own functions in order
+		// to handle errors and be able to use all functions from translator within the templates.
+		r = r.WithContext(context.WithValue(r.Context(), transKey, &translator{trans: t, Translator: t.(locales.Translator)}))
+
+		next(w, r)
+	}
+}
+
+func setup() {
+
+	en, _ := utrans.FindTranslator("en")
+	en.AddCardinal("days-left", "There is {0} day left", locales.PluralRuleOne, false)
+	en.AddCardinal("days-left", "There are {0} days left", locales.PluralRuleOther, false)
+
+	fr, _ := utrans.FindTranslator("fr")
+	fr.AddCardinal("days-left", "Il reste {0} jour", locales.PluralRuleOne, false)
+	fr.AddCardinal("days-left", "Il reste {0} jours", locales.PluralRuleOther, false)
+
+	err := utrans.VerifyTranslations()
+	if err != nil {
+		log.Fatal(err)
+	}
+}

+ 35 - 0
examples/full-with-files/home.tmpl

@@ -0,0 +1,35 @@
+{{ define "home" }}
+<!DOCTYPE html>
+<html>
+	<head>
+		<title>Home</title>
+	</head>
+	<body>
+		<p>Locale: {{ .Trans.Locale }}</p>
+        <p>Trans1: {{ .Trans.C "days-left" 1 0 "1" }}</p>
+        <p>Trans2: {{ .Trans.C "days-left" 2 0 "2" }}</p>
+        <p>FmtNumber Positive: {{ .Trans.FmtNumber .PositiveNum 2 }}</p>
+        <p>FmtNumber Negative: {{ .Trans.FmtNumber .NegativeNum 2 }}</p>
+        <p>FmtPercent Negative: {{ .Trans.FmtPercent .Percent 2 }}</p>
+        <p>FmtCurrency Negative: {{ .Trans.FmtCurrency .PositiveNum 2 .Trans.Currency }}</p>
+        <p>FmtCurrency Negative: {{ .Trans.FmtCurrency .NegativeNum 2 .Trans.Currency }}</p>
+        <p>FmtAccounting Negative: {{ .Trans.FmtAccounting .PositiveNum 2 .Trans.Currency }}</p>
+        <p>FmtAccounting Negative: {{ .Trans.FmtAccounting .NegativeNum 2 .Trans.Currency }}</p>
+        <p>FmtDateShort: {{ .Trans.FmtDateShort .Now }}</p>
+        <p>FmtDateMedium: {{ .Trans.FmtDateMedium .Now }}</p>
+        <p>FmtDateLong: {{ .Trans.FmtDateLong .Now }}</p>
+        <p>FmtDateFull: {{ .Trans.FmtDateFull .Now }}</p>
+        <p>FmtTimeShort: {{ .Trans.FmtTimeShort .Now }}</p>
+        <p>FmtTimeMedium: {{ .Trans.FmtTimeMedium .Now }}</p>
+        <p>FmtTimeLong: {{ .Trans.FmtTimeLong .Now }}</p>
+        <p>FmtTimeFull: {{ .Trans.FmtTimeFull .Now }}</p>
+        <p>MonthsAbbreviated: {{ .Trans.MonthsAbbreviated }}</p>
+        <p>MonthsNarrow: {{ .Trans.MonthsNarrow }}</p>
+        <p>MonthsWide: {{ .Trans.MonthsWide }}</p>
+        <p>WeekdaysAbbreviated: {{ .Trans.WeekdaysAbbreviated }}</p>
+        <p>WeekdaysNarrow: {{ .Trans.WeekdaysNarrow }}</p>
+        <p>WeekdaysShort: {{ .Trans.WeekdaysShort }}</p>
+        <p>WeekdaysWide: {{ .Trans.WeekdaysWide }}</p>
+	</body>
+</html>
+{{ end }}

+ 207 - 0
examples/full-with-files/main.go

@@ -0,0 +1,207 @@
+package main
+
+import (
+	"context"
+	"html/template"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/go-playground/locales"
+	"github.com/go-playground/locales/currency"
+	"github.com/go-playground/locales/en"
+	"github.com/go-playground/locales/fr"
+	"github.com/go-playground/pure"
+	"github.com/go-playground/pure/examples/middleware/logging-recovery"
+	"github.com/go-playground/universal-translator"
+)
+
+var (
+	tmpls    *template.Template
+	utrans   *ut.UniversalTranslator
+	transKey = struct {
+		name string
+	}{
+		name: "transKey",
+	}
+)
+
+// Translator wraps ut.Translator in order to handle errors transparently
+// it is totally optional but recommended as it can now be used directly in
+// templates and nobody can add translations where they're not supposed to.
+type Translator interface {
+	locales.Translator
+
+	// creates the translation for the locale given the 'key' and params passed in.
+	// wraps ut.Translator.T to handle errors
+	T(key interface{}, params ...string) string
+
+	// creates the cardinal translation for the locale given the 'key', 'num' and 'digit' arguments
+	//  and param passed in.
+	// wraps ut.Translator.C to handle errors
+	C(key interface{}, num float64, digits uint64, param string) string
+
+	// creates the ordinal translation for the locale given the 'key', 'num' and 'digit' arguments
+	// and param passed in.
+	// wraps ut.Translator.O to handle errors
+	O(key interface{}, num float64, digits uint64, param string) string
+
+	//  creates the range translation for the locale given the 'key', 'num1', 'digit1', 'num2' and
+	//  'digit2' arguments and 'param1' and 'param2' passed in
+	// wraps ut.Translator.R to handle errors
+	R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string
+
+	// Currency returns the type used by the given locale.
+	Currency() currency.Type
+}
+
+// implements Translator interface definition above.
+type translator struct {
+	locales.Translator
+	trans ut.Translator
+}
+
+var _ Translator = (*translator)(nil)
+
+func (t *translator) T(key interface{}, params ...string) string {
+
+	s, err := t.trans.T(key, params...)
+	if err != nil {
+		log.Printf("issue translating key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) C(key interface{}, num float64, digits uint64, param string) string {
+
+	s, err := t.trans.C(key, num, digits, param)
+	if err != nil {
+		log.Printf("issue translating cardinal key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) O(key interface{}, num float64, digits uint64, param string) string {
+
+	s, err := t.trans.C(key, num, digits, param)
+	if err != nil {
+		log.Printf("issue translating ordinal key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) R(key interface{}, num1 float64, digits1 uint64, num2 float64, digits2 uint64, param1, param2 string) string {
+
+	s, err := t.trans.R(key, num1, digits1, num2, digits2, param1, param2)
+	if err != nil {
+		log.Printf("issue translating range key: '%v' error: '%s'", key, err)
+	}
+
+	return s
+}
+
+func (t *translator) Currency() currency.Type {
+
+	// choose your own locale. The reason it isn't mapped for you is because many
+	// countries have multiple currencies; it's up to you and you're application how
+	// and which currencies to use. I recommend adding a function it to to your custon translator
+	// interface like defined above.
+	switch t.Locale() {
+	case "en":
+		return currency.USD
+	case "fr":
+		return currency.EUR
+	default:
+		return currency.USD
+	}
+}
+
+func main() {
+
+	en := en.New()
+	utrans = ut.New(en, en, fr.New())
+	setup()
+
+	tmpls, _ = template.ParseFiles("home.tmpl")
+
+	r := pure.New()
+	r.Use(middleware.LoggingAndRecovery(true), translatorMiddleware)
+	r.Get("/", home)
+
+	log.Println("Running on Port :8080")
+	log.Println("Try me with URL http://localhost:8080/?locale=en")
+	log.Println("and then http://localhost:8080/?locale=fr")
+	http.ListenAndServe(":8080", r.Serve())
+}
+
+func home(w http.ResponseWriter, r *http.Request) {
+
+	// get locale translator ( could be wrapped into a helper function )
+	t := r.Context().Value(transKey).(Translator)
+
+	s := struct {
+		Trans       Translator
+		Now         time.Time
+		PositiveNum float64
+		NegativeNum float64
+		Percent     float64
+	}{
+		Trans:       t,
+		Now:         time.Now(),
+		PositiveNum: 1234576.45,
+		NegativeNum: -35900394.34,
+		Percent:     96.76,
+	}
+
+	if err := tmpls.ExecuteTemplate(w, "home", s); err != nil {
+		log.Fatal(err)
+	}
+}
+
+func translatorMiddleware(next http.HandlerFunc) http.HandlerFunc {
+
+	return func(w http.ResponseWriter, r *http.Request) {
+
+		// there are many ways to check, this is just checking for query param &
+		// Accept-Language header but can be expanded to Cookie's etc....
+
+		params := r.URL.Query()
+
+		locale := params.Get("locale")
+		var t ut.Translator
+
+		if len(locale) > 0 {
+
+			var found bool
+
+			if t, found = utrans.GetTranslator(locale); found {
+				goto END
+			}
+		}
+
+		// get and parse the "Accept-Language" http header and return an array
+		t, _ = utrans.FindTranslator(pure.AcceptedLanguages(r)...)
+	END:
+		// I would normally wrap ut.Translator with one with my own functions in order
+		// to handle errors and be able to use all functions from translator within the templates.
+		r = r.WithContext(context.WithValue(r.Context(), transKey, &translator{trans: t, Translator: t.(locales.Translator)}))
+
+		next(w, r)
+	}
+}
+
+func setup() {
+
+	err := utrans.Import(ut.FormatJSON, "translations")
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	err = utrans.VerifyTranslations()
+	if err != nil {
+		log.Fatal(err)
+	}
+}

+ 16 - 0
examples/full-with-files/translations/en/home.json

@@ -0,0 +1,16 @@
+[
+    {
+        "locale": "en",
+        "key": "days-left",
+        "trans": "There is {0} day left",
+        "type": "Cardinal",
+        "rule": "One"
+    },
+    {
+        "locale": "en",
+        "key": "days-left",
+        "trans": "There are {0} days left",
+        "type": "Cardinal",
+        "rule": "Other"
+    }
+]

+ 16 - 0
examples/full-with-files/translations/fr/home.json

@@ -0,0 +1,16 @@
+[
+    {
+        "locale": "fr",
+        "key": "days-left",
+        "trans": "Il reste {0} jour",
+        "type": "Cardinal",
+        "rule": "One"
+    },
+    {
+        "locale": "fr",
+        "key": "days-left",
+        "trans": "Il reste {0} jours",
+        "type": "Cardinal",
+        "rule": "Other"
+    }
+]

+ 274 - 0
import_export.go

@@ -0,0 +1,274 @@
+package ut
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"io"
+
+	"github.com/go-playground/locales"
+)
+
+type translation struct {
+	Locale           string      `json:"locale"`
+	Key              interface{} `json:"key"` // either string or integer
+	Translation      string      `json:"trans"`
+	PluralType       string      `json:"type,omitempty"`
+	PluralRule       string      `json:"rule,omitempty"`
+	OverrideExisting bool        `json:"override,omitempty"`
+}
+
+const (
+	cardinalType = "Cardinal"
+	ordinalType  = "Ordinal"
+	rangeType    = "Range"
+)
+
+// ImportExportFormat is the format of the file import or export
+type ImportExportFormat uint8
+
+// supported Export Formats
+const (
+	FormatJSON ImportExportFormat = iota
+)
+
+// Export writes the translations out to a file on disk.
+//
+// NOTE: this currently only works with string or int translations keys.
+func (t *UniversalTranslator) Export(format ImportExportFormat, dirname string) error {
+
+	_, err := os.Stat(dirname)
+	fmt.Println(dirname, err, os.IsNotExist(err))
+	if err != nil {
+
+		if !os.IsNotExist(err) {
+			return err
+		}
+
+		if err = os.MkdirAll(dirname, 0744); err != nil {
+			return err
+		}
+	}
+
+	// build up translations
+	var trans []translation
+	var b []byte
+	var ext string
+
+	for _, locale := range t.translators {
+
+		for k, v := range locale.(*translator).translations {
+			trans = append(trans, translation{
+				Locale:      locale.Locale(),
+				Key:         k,
+				Translation: v.text,
+			})
+		}
+
+		for k, pluralTrans := range locale.(*translator).cardinalTanslations {
+
+			for i, plural := range pluralTrans {
+
+				// leave enough for all plural rules
+				// but not all are set for all languages.
+				if plural == nil {
+					continue
+				}
+
+				trans = append(trans, translation{
+					Locale:      locale.Locale(),
+					Key:         k.(string),
+					Translation: plural.text,
+					PluralType:  cardinalType,
+					PluralRule:  locales.PluralRule(i).String(),
+				})
+			}
+		}
+
+		for k, pluralTrans := range locale.(*translator).ordinalTanslations {
+
+			for i, plural := range pluralTrans {
+
+				// leave enough for all plural rules
+				// but not all are set for all languages.
+				if plural == nil {
+					continue
+				}
+
+				trans = append(trans, translation{
+					Locale:      locale.Locale(),
+					Key:         k.(string),
+					Translation: plural.text,
+					PluralType:  ordinalType,
+					PluralRule:  locales.PluralRule(i).String(),
+				})
+			}
+		}
+
+		for k, pluralTrans := range locale.(*translator).rangeTanslations {
+
+			for i, plural := range pluralTrans {
+
+				// leave enough for all plural rules
+				// but not all are set for all languages.
+				if plural == nil {
+					continue
+				}
+
+				trans = append(trans, translation{
+					Locale:      locale.Locale(),
+					Key:         k.(string),
+					Translation: plural.text,
+					PluralType:  rangeType,
+					PluralRule:  locales.PluralRule(i).String(),
+				})
+			}
+		}
+
+		switch format {
+		case FormatJSON:
+			b, err = json.MarshalIndent(trans, "", "    ")
+			ext = ".json"
+		}
+
+		if err != nil {
+			return err
+		}
+
+		err = ioutil.WriteFile(filepath.Join(dirname, fmt.Sprintf("%s%s", locale.Locale(), ext)), b, 0644)
+		if err != nil {
+			return err
+		}
+
+		trans = trans[0:0]
+	}
+
+	return nil
+}
+
+// Import reads the translations out of a file or directory on disk.
+//
+// NOTE: this currently only works with string or int translations keys.
+func (t *UniversalTranslator) Import(format ImportExportFormat, dirnameOrFilename string) error {
+
+	fi, err := os.Stat(dirnameOrFilename)
+	if err != nil {
+		return err
+	}
+
+	processFn := func(filename string) error {
+
+		f, err := os.Open(filename)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+
+		return t.ImportByReader(format, f)
+	}
+
+	if !fi.IsDir() {
+		return processFn(dirnameOrFilename)
+	}
+
+	// recursively go through directory
+	walker := func(path string, info os.FileInfo, err error) error {
+
+		if info.IsDir() {
+			return nil
+		}
+
+		switch format {
+		case FormatJSON:
+			// skip non JSON files
+			if filepath.Ext(info.Name()) != ".json" {
+				return nil
+			}
+		}
+
+		return processFn(path)
+	}
+
+	return filepath.Walk(dirnameOrFilename, walker)
+}
+
+// ImportByReader imports the the translations found within the contents read from the supplied reader.
+//
+// NOTE: generally used when assets have been embedded into the binary and are already in memory.
+func (t *UniversalTranslator) ImportByReader(format ImportExportFormat, reader io.Reader) error {
+
+	b, err := ioutil.ReadAll(reader)
+	if err != nil {
+		return err
+	}
+
+	var trans []translation
+
+	switch format {
+	case FormatJSON:
+		err = json.Unmarshal(b, &trans)
+	}
+
+	if err != nil {
+		return err
+	}
+
+	for _, tl := range trans {
+
+		locale, found := t.FindTranslator(tl.Locale)
+		if !found {
+			return &ErrMissingLocale{locale: tl.Locale}
+		}
+
+		pr := stringToPR(tl.PluralRule)
+
+		if pr == locales.PluralRuleUnknown {
+
+			err = locale.Add(tl.Key, tl.Translation, tl.OverrideExisting)
+			if err != nil {
+				return err
+			}
+
+			continue
+		}
+
+		switch tl.PluralType {
+		case cardinalType:
+			err = locale.AddCardinal(tl.Key, tl.Translation, pr, tl.OverrideExisting)
+		case ordinalType:
+			err = locale.AddOrdinal(tl.Key, tl.Translation, pr, tl.OverrideExisting)
+		case rangeType:
+			err = locale.AddRange(tl.Key, tl.Translation, pr, tl.OverrideExisting)
+		default:
+			return &ErrBadPluralDefinition{tl: tl}
+		}
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func stringToPR(s string) locales.PluralRule {
+
+	switch s {
+	case "One":
+		return locales.PluralRuleOne
+	case "Two":
+		return locales.PluralRuleTwo
+	case "Few":
+		return locales.PluralRuleFew
+	case "Many":
+		return locales.PluralRuleMany
+	case "Other":
+		return locales.PluralRuleOther
+	default:
+		return locales.PluralRuleUnknown
+	}
+
+}

+ 789 - 0
import_export_test.go

@@ -0,0 +1,789 @@
+package ut
+
+import (
+	"fmt"
+	"path/filepath"
+	"testing"
+
+	"os"
+
+	"github.com/go-playground/locales"
+	"github.com/go-playground/locales/en"
+	"github.com/go-playground/locales/nl"
+)
+
+// NOTES:
+// - Run "go test" to run tests
+// - Run "gocov test | gocov report" to report on test converage by file
+// - Run "gocov test | gocov annotate -" to report on all code and functions, those ,marked with "MISS" were never called
+//
+// or
+//
+// -- may be a good idea to change to output path to somewherelike /tmp
+// go test -coverprofile cover.out && go tool cover -html=cover.out -o cover.html
+//
+
+func TestExportImportBasic(t *testing.T) {
+
+	e := en.New()
+	uni := New(e, e)
+	en, found := uni.GetTranslator("en") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	translations := []struct {
+		key           interface{}
+		trans         string
+		expected      error
+		expectedError bool
+		override      bool
+	}{
+		{
+			key:      "test_trans",
+			trans:    "Welcome {0}",
+			expected: nil,
+		},
+		{
+			key:      -1,
+			trans:    "Welcome {0}",
+			expected: nil,
+		},
+		{
+			key:      "test_trans2",
+			trans:    "{0} to the {1}.",
+			expected: nil,
+		},
+		{
+			key:      "test_trans3",
+			trans:    "Welcome {0} to the {1}",
+			expected: nil,
+		},
+		{
+			key:      "test_trans4",
+			trans:    "{0}{1}",
+			expected: nil,
+		},
+		{
+			key:           "test_trans",
+			trans:         "{0}{1}",
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: "test_trans", text: "{0}{1}"},
+			expectedError: true,
+		},
+		{
+			key:           -1,
+			trans:         "{0}{1}",
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: -1, text: "{0}{1}"},
+			expectedError: true,
+		},
+		{
+			key:      "test_trans",
+			trans:    "Welcome {0} to the {1}.",
+			expected: nil,
+			override: true,
+		},
+	}
+
+	for _, tt := range translations {
+
+		err := en.Add(tt.key, tt.trans, tt.override)
+		if err != tt.expected {
+			if !tt.expectedError {
+				t.Errorf("Expected '%s' Got '%s'", tt.expected, err)
+			} else {
+				if err.Error() != tt.expected.Error() {
+					t.Errorf("Expected '%s' Got '%s'", tt.expected.Error(), err.Error())
+				}
+			}
+		}
+	}
+
+	dirname := "testdata/translations"
+	defer os.RemoveAll(dirname)
+
+	err := uni.Export(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	uni = New(e, e)
+
+	err = uni.Import(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	en, found = uni.GetTranslator("en") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	tests := []struct {
+		key           interface{}
+		params        []string
+		expected      string
+		expectedError bool
+	}{
+		{
+			key:      "test_trans",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "Welcome Joeybloggs to the The Test.",
+		},
+		{
+			key:      "test_trans2",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "Joeybloggs to the The Test.",
+		},
+		{
+			key:      "test_trans3",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "Welcome Joeybloggs to the The Test",
+		},
+		{
+			key:      "test_trans4",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "JoeybloggsThe Test",
+		},
+		// bad translation
+		{
+			key:           "non-existant-key",
+			params:        []string{"Joeybloggs", "The Test"},
+			expected:      "",
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+
+		s, err := en.T(tt.key, tt.params...)
+		if s != tt.expected {
+			if !tt.expectedError || (tt.expectedError && err != ErrUnknowTranslation) {
+				t.Errorf("Expected '%s' Got '%s'", tt.expected, s)
+			}
+		}
+	}
+}
+
+func TestExportImportCardinal(t *testing.T) {
+
+	e := en.New()
+	uni := New(e, e)
+	en, found := uni.GetTranslator("en")
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	translations := []struct {
+		key           interface{}
+		trans         string
+		rule          locales.PluralRule
+		expected      error
+		expectedError bool
+		override      bool
+	}{
+		// bad translation
+		{
+			key:           "cardinal_test",
+			trans:         "You have a day left.",
+			rule:          locales.PluralRuleOne,
+			expected:      &ErrCardinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddCardinal'. locale: '%s' key: '%v' text: '%s'", paramZero, en.Locale(), "cardinal_test", "You have a day left.")},
+			expectedError: true,
+		},
+		{
+			key:      "cardinal_test",
+			trans:    "You have {0} day",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+		},
+		{
+			key:      "cardinal_test",
+			trans:    "You have {0} days left.",
+			rule:     locales.PluralRuleOther,
+			expected: nil,
+		},
+		{
+			key:           "cardinal_test",
+			trans:         "You have {0} days left.",
+			rule:          locales.PluralRuleOther,
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: "cardinal_test", rule: locales.PluralRuleOther, text: "You have {0} days left."},
+			expectedError: true,
+		},
+		{
+			key:      "cardinal_test",
+			trans:    "You have {0} day left.",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+			override: true,
+		},
+	}
+
+	for _, tt := range translations {
+
+		err := en.AddCardinal(tt.key, tt.trans, tt.rule, tt.override)
+		if err != tt.expected {
+			if !tt.expectedError || err.Error() != tt.expected.Error() {
+				t.Errorf("Expected '%s' Got '%s'", tt.expected, err)
+			}
+		}
+	}
+
+	dirname := "testdata/translations"
+	defer os.RemoveAll(dirname)
+
+	err := uni.Export(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	uni = New(e, e)
+
+	err = uni.Import(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	en, found = uni.GetTranslator("en") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	tests := []struct {
+		key           interface{}
+		num           float64
+		digits        uint64
+		param         string
+		expected      string
+		expectedError bool
+	}{
+		{
+			key:      "cardinal_test",
+			num:      1,
+			digits:   0,
+			param:    string(en.FmtNumber(1, 0)),
+			expected: "You have 1 day left.",
+		},
+		// bad translation key
+		{
+			key:           "non-existant",
+			num:           1,
+			digits:        0,
+			param:         string(en.FmtNumber(1, 0)),
+			expected:      "",
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+
+		s, err := en.C(tt.key, tt.num, tt.digits, tt.param)
+		if err != nil {
+			if !tt.expectedError && err != ErrUnknowTranslation {
+				t.Errorf("Expected '<nil>' Got '%s'", err)
+			}
+		}
+
+		if s != tt.expected {
+			t.Errorf("Expected '%s' Got '%s'", tt.expected, s)
+		}
+	}
+}
+
+func TestExportImportOrdinal(t *testing.T) {
+
+	e := en.New()
+	uni := New(e, e)
+	en, found := uni.GetTranslator("en")
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	translations := []struct {
+		key           interface{}
+		trans         string
+		rule          locales.PluralRule
+		expected      error
+		expectedError bool
+		override      bool
+	}{
+		// bad translation
+		{
+			key:           "day",
+			trans:         "st",
+			rule:          locales.PluralRuleOne,
+			expected:      &ErrOrdinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddOrdinal'. locale: '%s' key: '%v' text: '%s'", paramZero, en.Locale(), "day", "st")},
+			expectedError: true,
+		},
+		{
+			key:      "day",
+			trans:    "{0}sfefewt",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+		},
+		{
+			key:      "day",
+			trans:    "{0}nd",
+			rule:     locales.PluralRuleTwo,
+			expected: nil,
+		},
+		{
+			key:      "day",
+			trans:    "{0}rd",
+			rule:     locales.PluralRuleFew,
+			expected: nil,
+		},
+		{
+			key:      "day",
+			trans:    "{0}th",
+			rule:     locales.PluralRuleOther,
+			expected: nil,
+		},
+		// bad translation
+		{
+			key:           "day",
+			trans:         "{0}th",
+			rule:          locales.PluralRuleOther,
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: "day", rule: locales.PluralRuleOther, text: "{0}th"},
+			expectedError: true,
+		},
+		{
+			key:      "day",
+			trans:    "{0}st",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+			override: true,
+		},
+	}
+
+	for _, tt := range translations {
+
+		err := en.AddOrdinal(tt.key, tt.trans, tt.rule, tt.override)
+		if err != tt.expected {
+			if !tt.expectedError || err.Error() != tt.expected.Error() {
+				t.Errorf("Expected '<nil>' Got '%s'", err)
+			}
+		}
+	}
+
+	dirname := "testdata/translations"
+	defer os.RemoveAll(dirname)
+
+	err := uni.Export(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	uni = New(e, e)
+
+	err = uni.Import(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	en, found = uni.GetTranslator("en") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	tests := []struct {
+		key           interface{}
+		num           float64
+		digits        uint64
+		param         string
+		expected      string
+		expectedError bool
+	}{
+		{
+			key:      "day",
+			num:      1,
+			digits:   0,
+			param:    string(en.FmtNumber(1, 0)),
+			expected: "1st",
+		},
+		{
+			key:      "day",
+			num:      2,
+			digits:   0,
+			param:    string(en.FmtNumber(2, 0)),
+			expected: "2nd",
+		},
+		{
+			key:      "day",
+			num:      3,
+			digits:   0,
+			param:    string(en.FmtNumber(3, 0)),
+			expected: "3rd",
+		},
+		{
+			key:      "day",
+			num:      4,
+			digits:   0,
+			param:    string(en.FmtNumber(4, 0)),
+			expected: "4th",
+		},
+		{
+			key:      "day",
+			num:      10258.43,
+			digits:   0,
+			param:    string(en.FmtNumber(10258.43, 0)),
+			expected: "10,258th",
+		},
+		// bad translation
+		{
+			key:           "d-day",
+			num:           10258.43,
+			digits:        0,
+			param:         string(en.FmtNumber(10258.43, 0)),
+			expected:      "",
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+
+		s, err := en.O(tt.key, tt.num, tt.digits, tt.param)
+		if err != nil {
+			if !tt.expectedError && err != ErrUnknowTranslation {
+				t.Errorf("Expected '<nil>' Got '%s'", err)
+			}
+		}
+
+		if s != tt.expected {
+			t.Errorf("Expected '%s' Got '%s'", tt.expected, s)
+		}
+	}
+}
+
+func TestExportImportRange(t *testing.T) {
+
+	n := nl.New()
+	uni := New(n, n)
+
+	// dutch
+	nl, found := uni.GetTranslator("nl")
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	translations := []struct {
+		key           interface{}
+		trans         string
+		rule          locales.PluralRule
+		expected      error
+		expectedError bool
+		override      bool
+	}{
+		// bad translation
+		{
+			key:           "day",
+			trans:         "er -{1} dag vertrokken",
+			rule:          locales.PluralRuleOne,
+			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, are you sure you're adding a Range Translation? locale: '%s' key: '%s' text: '%s'", paramZero, nl.Locale(), "day", "er -{1} dag vertrokken")},
+			expectedError: true,
+		},
+		// bad translation
+		{
+			key:           "day",
+			trans:         "er {0}- dag vertrokken",
+			rule:          locales.PluralRuleOne,
+			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, a Range Translation requires two parameters. locale: '%s' key: '%s' text: '%s'", paramOne, nl.Locale(), "day", "er {0}- dag vertrokken")},
+			expectedError: true,
+		},
+		{
+			key:      "day",
+			trans:    "er {0}-{1} dag",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+		},
+		{
+			key:      "day",
+			trans:    "er zijn {0}-{1} dagen over",
+			rule:     locales.PluralRuleOther,
+			expected: nil,
+		},
+		// bad translation
+		{
+			key:           "day",
+			trans:         "er zijn {0}-{1} dagen over",
+			rule:          locales.PluralRuleOther,
+			expected:      &ErrConflictingTranslation{locale: nl.Locale(), key: "day", rule: locales.PluralRuleOther, text: "er zijn {0}-{1} dagen over"},
+			expectedError: true,
+		},
+		{
+			key:      "day",
+			trans:    "er {0}-{1} dag vertrokken",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+			override: true,
+		},
+	}
+
+	for _, tt := range translations {
+
+		err := nl.AddRange(tt.key, tt.trans, tt.rule, tt.override)
+		if err != tt.expected {
+			if !tt.expectedError || err.Error() != tt.expected.Error() {
+				t.Errorf("Expected '%#v' Got '%s'", tt.expected, err)
+			}
+		}
+	}
+
+	dirname := "testdata/translations"
+	defer os.RemoveAll(dirname)
+
+	err := uni.Export(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	uni = New(n, n)
+
+	err = uni.Import(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	nl, found = uni.GetTranslator("nl") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	tests := []struct {
+		key           interface{}
+		num1          float64
+		digits1       uint64
+		num2          float64
+		digits2       uint64
+		param1        string
+		param2        string
+		expected      string
+		expectedError bool
+	}{
+		{
+			key:      "day",
+			num1:     1,
+			digits1:  0,
+			num2:     2,
+			digits2:  0,
+			param1:   string(nl.FmtNumber(1, 0)),
+			param2:   string(nl.FmtNumber(2, 0)),
+			expected: "er zijn 1-2 dagen over",
+		},
+		{
+			key:      "day",
+			num1:     0,
+			digits1:  0,
+			num2:     1,
+			digits2:  0,
+			param1:   string(nl.FmtNumber(0, 0)),
+			param2:   string(nl.FmtNumber(1, 0)),
+			expected: "er 0-1 dag vertrokken",
+		},
+		{
+			key:      "day",
+			num1:     0,
+			digits1:  0,
+			num2:     2,
+			digits2:  0,
+			param1:   string(nl.FmtNumber(0, 0)),
+			param2:   string(nl.FmtNumber(2, 0)),
+			expected: "er zijn 0-2 dagen over",
+		},
+		// bad translations from here
+		{
+			key:           "d-day",
+			num1:          0,
+			digits1:       0,
+			num2:          2,
+			digits2:       0,
+			param1:        string(nl.FmtNumber(0, 0)),
+			param2:        string(nl.FmtNumber(2, 0)),
+			expected:      "",
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+
+		s, err := nl.R(tt.key, tt.num1, tt.digits1, tt.num2, tt.digits2, tt.param1, tt.param2)
+		if err != nil {
+			if !tt.expectedError && err != ErrUnknowTranslation {
+				t.Errorf("Expected '<nil>' Got '%s'", err)
+			}
+		}
+
+		if s != tt.expected {
+			t.Errorf("Expected '%s' Got '%s'", tt.expected, s)
+		}
+	}
+}
+
+func TestImportRecursive(t *testing.T) {
+
+	e := en.New()
+	uni := New(e, e)
+
+	dirname := "testdata/nested1"
+	err := uni.Import(FormatJSON, dirname)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	en, found := uni.GetTranslator("en") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	tests := []struct {
+		key           interface{}
+		params        []string
+		expected      string
+		expectedError bool
+	}{
+		{
+			key:      "test_trans",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "Welcome Joeybloggs to the The Test.",
+		},
+		{
+			key:      "test_trans2",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "Joeybloggs to the The Test.",
+		},
+		{
+			key:      "test_trans3",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "Welcome Joeybloggs to the The Test",
+		},
+		{
+			key:      "test_trans4",
+			params:   []string{"Joeybloggs", "The Test"},
+			expected: "JoeybloggsThe Test",
+		},
+		// bad translation
+		{
+			key:           "non-existant-key",
+			params:        []string{"Joeybloggs", "The Test"},
+			expected:      "",
+			expectedError: true,
+		},
+	}
+
+	for _, tt := range tests {
+
+		s, err := en.T(tt.key, tt.params...)
+		if s != tt.expected {
+			if !tt.expectedError || (tt.expectedError && err != ErrUnknowTranslation) {
+				t.Errorf("Expected '%s' Got '%s'", tt.expected, s)
+			}
+		}
+	}
+}
+
+func TestBadImport(t *testing.T) {
+
+	// test non existant file
+	e := en.New()
+	uni := New(e, e)
+
+	filename := "testdata/non-existant-file.json"
+	expected := "stat testdata/non-existant-file.json: no such file or directory"
+	err := uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test bad parameter basic translation
+	filename = "testdata/bad-translation1.json"
+	expected = "error: bad parameter syntax, missing parameter '{0}' in translation. locale: 'en' key: 'test_trans3' text: 'Welcome {lettersnotpermitted} to the {1}'"
+	err = uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test missing bracket basic translation
+	filename = "testdata/bad-translation2.json"
+	expected = "error: missing bracket '{}', in translation. locale: 'en' key: 'test_trans3' text: 'Welcome {0 to the {1}'"
+	err = uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test missing locale basic translation
+	filename = "testdata/bad-translation3.json"
+	expected = "error: locale 'nl' not registered."
+	err = uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test bad plural definition
+	filename = "testdata/bad-translation4.json"
+	expected = "error: bad plural definition 'ut.translation{Locale:\"en\", Key:\"cardinal_test\", Translation:\"You have {0} day left.\", PluralType:\"NotAPluralType\", PluralRule:\"One\", OverrideExisting:false}'"
+	err = uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test bad plural rule for locale
+	filename = "testdata/bad-translation5.json"
+	expected = "error: cardinal plural rule 'Many' does not exist for locale 'en' key: 'cardinal_test' text: 'You have {0} day left.'"
+	err = uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test invalid JSON
+	filename = "testdata/bad-translation6.json"
+	expected = "invalid character ']' after object key:value pair"
+	err = uni.Import(FormatJSON, filename)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test bad io.Reader
+	f, err := os.Open(filename)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+	f.Close()
+
+	expected = "read testdata/bad-translation6.json: bad file descriptor"
+	err = uni.ImportByReader(FormatJSON, f)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+}
+
+func TestBadExport(t *testing.T) {
+
+	// test readonly directory
+	e := en.New()
+	uni := New(e, e)
+
+	en, found := uni.GetTranslator("en") // or fallback if fails to find 'en'
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	dirname := "testdata/readonly"
+	err := os.Mkdir(dirname, 0444)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+	defer os.RemoveAll(dirname)
+
+	en.Add("day", "this is a day", false)
+
+	expected := "open testdata/readonly/en.json: permission denied"
+	err = uni.Export(FormatJSON, dirname)
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	// test exporting into directory inside readonly directory
+	expected = "stat testdata/readonly/inner: permission denied"
+	err = uni.Export(FormatJSON, filepath.Join(dirname, "inner"))
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+}

+ 11 - 0
testdata/.gitignore

@@ -0,0 +1,11 @@
+*
+
+!.gitignore
+!*/
+!bad-translation1.json
+!bad-translation2.json
+!bad-translation3.json
+!bad-translation4.json
+!bad-translation5.json
+!bad-translation6.json
+!/nested1/**

+ 7 - 0
testdata/bad-translation1.json

@@ -0,0 +1,7 @@
+[
+    {
+        "locale": "en",
+        "key": "test_trans3",
+        "trans": "Welcome {lettersnotpermitted} to the {1}"
+    }
+]

+ 7 - 0
testdata/bad-translation2.json

@@ -0,0 +1,7 @@
+[
+    {
+        "locale": "en",
+        "key": "test_trans3",
+        "trans": "Welcome {0 to the {1}"
+    }
+]

+ 7 - 0
testdata/bad-translation3.json

@@ -0,0 +1,7 @@
+[
+    {
+        "locale": "nl",
+        "key": "test_trans3",
+        "trans": "Welcome {0 to the {1}"
+    }
+]

+ 9 - 0
testdata/bad-translation4.json

@@ -0,0 +1,9 @@
+[
+    {
+        "locale": "en",
+        "key": "cardinal_test",
+        "trans": "You have {0} day left.",
+        "type": "NotAPluralType",
+        "rule": "One"
+    }
+]

+ 9 - 0
testdata/bad-translation5.json

@@ -0,0 +1,9 @@
+[
+    {
+        "locale": "en",
+        "key": "cardinal_test",
+        "trans": "You have {0} day left.",
+        "type": "Cardinal",
+        "rule": "Many"
+    }
+]

+ 9 - 0
testdata/bad-translation6.json

@@ -0,0 +1,9 @@
+[
+    {
+        "locale": "en",
+        "key": "cardinal_test",
+        "trans": "You have {0} day left.",
+        "type": "Cardinal",
+        "rule": "Many"
+    
+]

+ 12 - 0
testdata/nested1/nested1.json

@@ -0,0 +1,12 @@
+[
+    {
+        "locale": "en",
+        "key": -1,
+        "trans": "Welcome {0}"
+    },
+    {
+        "locale": "en",
+        "key": "test_trans2",
+        "trans": "{0} to the {1}."
+    }
+]

+ 17 - 0
testdata/nested1/nested2/nested2.json

@@ -0,0 +1,17 @@
+[
+    {
+        "locale": "en",
+        "key": "test_trans3",
+        "trans": "Welcome {0} to the {1}"
+    },
+    {
+        "locale": "en",
+        "key": "test_trans4",
+        "trans": "{0}{1}"
+    },
+    {
+        "locale": "en",
+        "key": "test_trans",
+        "trans": "Welcome {0} to the {1}."
+    }
+]

+ 0 - 0
testdata/nested1/nested2/unrelated-file.txt


+ 62 - 15
translator.go

@@ -98,26 +98,31 @@ func newTranslator(trans locales.Translator) Translator {
 func (t *translator) Add(key interface{}, text string, override bool) error {
 
 	if _, ok := t.translations[key]; ok && !override {
-		return &ErrConflictingTranslation{key: key, text: text}
+		return &ErrConflictingTranslation{locale: t.Locale(), key: key, text: text}
+	}
+
+	lb := strings.Count(text, "{")
+	rb := strings.Count(text, "}")
+
+	if lb != rb {
+		return &ErrMissingBracket{locale: t.Locale(), key: key, text: text}
 	}
 
 	trans := &transText{
 		text: text,
 	}
 
-	var i int
 	var idx int
 
-	for {
+	for i := 0; i < lb; i++ {
 		s := "{" + strconv.Itoa(i) + "}"
 		idx = strings.Index(text, s)
 		if idx == -1 {
-			break
+			return &ErrBadParamSyntax{locale: t.Locale(), param: s, key: key, text: text}
 		}
 
 		trans.indexes = append(trans.indexes, idx)
 		trans.indexes = append(trans.indexes, idx+len(s))
-		i++
 	}
 
 	t.translations[key] = trans
@@ -132,11 +137,25 @@ func (t *translator) Add(key interface{}, text string, override bool) error {
 // eg. in locale 'en' one: '{0} day left' other: '{0} days left'
 func (t *translator) AddCardinal(key interface{}, text string, rule locales.PluralRule, override bool) error {
 
+	var verified bool
+
+	// verify plural rule exists for locale
+	for _, pr := range t.PluralsCardinal() {
+		if pr == rule {
+			verified = true
+			break
+		}
+	}
+
+	if !verified {
+		return &ErrCardinalTranslation{text: fmt.Sprintf("error: cardinal plural rule '%s' does not exist for locale '%s' key: '%v' text: '%s'", rule, t.Locale(), key, text)}
+	}
+
 	tarr, ok := t.cardinalTanslations[key]
 	if ok {
 		// verify not adding a conflicting record
 		if len(tarr) > 0 && tarr[rule] != nil && !override {
-			return &ErrConflictingTranslation{key: key, rule: rule, text: text}
+			return &ErrConflictingTranslation{locale: t.Locale(), key: key, rule: rule, text: text}
 		}
 
 	} else {
@@ -154,7 +173,7 @@ func (t *translator) AddCardinal(key interface{}, text string, rule locales.Plur
 	idx := strings.Index(text, paramZero)
 	if idx == -1 {
 		tarr[rule] = nil
-		return &ErrCardinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddCardinal'", paramZero)}
+		return &ErrCardinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddCardinal'. locale: '%s' key: '%v' text: '%s'", paramZero, t.Locale(), key, text)}
 	}
 
 	trans.indexes[0] = idx
@@ -170,11 +189,25 @@ func (t *translator) AddCardinal(key interface{}, text string, rule locales.Plur
 // eg. in locale 'en' one: '{0}st day of spring' other: '{0}nd day of spring' - 1st, 2nd, 3rd...
 func (t *translator) AddOrdinal(key interface{}, text string, rule locales.PluralRule, override bool) error {
 
+	var verified bool
+
+	// verify plural rule exists for locale
+	for _, pr := range t.PluralsOrdinal() {
+		if pr == rule {
+			verified = true
+			break
+		}
+	}
+
+	if !verified {
+		return &ErrOrdinalTranslation{text: fmt.Sprintf("error: ordinal plural rule '%s' does not exist for locale '%s' key: '%v' text: '%s'", rule, t.Locale(), key, text)}
+	}
+
 	tarr, ok := t.ordinalTanslations[key]
 	if ok {
 		// verify not adding a conflicting record
 		if len(tarr) > 0 && tarr[rule] != nil && !override {
-			return &ErrConflictingTranslation{key: key, rule: rule, text: text}
+			return &ErrConflictingTranslation{locale: t.Locale(), key: key, rule: rule, text: text}
 		}
 
 	} else {
@@ -192,7 +225,7 @@ func (t *translator) AddOrdinal(key interface{}, text string, rule locales.Plura
 	idx := strings.Index(text, paramZero)
 	if idx == -1 {
 		tarr[rule] = nil
-		return &ErrOrdinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddOrdinal'", paramZero)}
+		return &ErrOrdinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddOrdinal'. locale: '%s' key: '%v' text: '%s'", paramZero, t.Locale(), key, text)}
 	}
 
 	trans.indexes[0] = idx
@@ -206,11 +239,25 @@ func (t *translator) AddOrdinal(key interface{}, text string, rule locales.Plura
 // eg. in locale 'nl' one: '{0}-{1} day left' other: '{0}-{1} days left'
 func (t *translator) AddRange(key interface{}, text string, rule locales.PluralRule, override bool) error {
 
+	var verified bool
+
+	// verify plural rule exists for locale
+	for _, pr := range t.PluralsRange() {
+		if pr == rule {
+			verified = true
+			break
+		}
+	}
+
+	if !verified {
+		return &ErrRangeTranslation{text: fmt.Sprintf("error: range plural rule '%s' does not exist for locale '%s' key: '%v' text: '%s'", rule, t.Locale(), key, text)}
+	}
+
 	tarr, ok := t.rangeTanslations[key]
 	if ok {
 		// verify not adding a conflicting record
 		if len(tarr) > 0 && tarr[rule] != nil && !override {
-			return &ErrConflictingTranslation{key: key, rule: rule, text: text}
+			return &ErrConflictingTranslation{locale: t.Locale(), key: key, rule: rule, text: text}
 		}
 
 	} else {
@@ -228,7 +275,7 @@ func (t *translator) AddRange(key interface{}, text string, rule locales.PluralR
 	idx := strings.Index(text, paramZero)
 	if idx == -1 {
 		tarr[rule] = nil
-		return &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, are you sure you're adding a Range Translation?", paramZero)}
+		return &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, are you sure you're adding a Range Translation? locale: '%s' key: '%v' text: '%s'", paramZero, t.Locale(), key, text)}
 	}
 
 	trans.indexes[0] = idx
@@ -237,7 +284,7 @@ func (t *translator) AddRange(key interface{}, text string, rule locales.PluralR
 	idx = strings.Index(text, paramOne)
 	if idx == -1 {
 		tarr[rule] = nil
-		return &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, a Range Translation requires two parameters", paramOne)}
+		return &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, a Range Translation requires two parameters. locale: '%s' key: '%v' text: '%s'", paramOne, t.Locale(), key, text)}
 	}
 
 	trans.indexes[2] = idx
@@ -344,7 +391,7 @@ func (t *translator) VerifyTranslations() error {
 		for _, rule := range t.PluralsCardinal() {
 
 			if v[rule] == nil {
-				return &ErrMissingPluralTranslation{translationType: "plural", rule: rule, key: k}
+				return &ErrMissingPluralTranslation{locale: t.Locale(), translationType: "plural", rule: rule, key: k}
 			}
 		}
 	}
@@ -354,7 +401,7 @@ func (t *translator) VerifyTranslations() error {
 		for _, rule := range t.PluralsOrdinal() {
 
 			if v[rule] == nil {
-				return &ErrMissingPluralTranslation{translationType: "ordinal", rule: rule, key: k}
+				return &ErrMissingPluralTranslation{locale: t.Locale(), translationType: "ordinal", rule: rule, key: k}
 			}
 		}
 	}
@@ -364,7 +411,7 @@ func (t *translator) VerifyTranslations() error {
 		for _, rule := range t.PluralsRange() {
 
 			if v[rule] == nil {
-				return &ErrMissingPluralTranslation{translationType: "range", rule: rule, key: k}
+				return &ErrMissingPluralTranslation{locale: t.Locale(), translationType: "range", rule: rule, key: k}
 			}
 		}
 	}

+ 109 - 14
translator_test.go

@@ -65,13 +65,13 @@ func TestBasicTranslation(t *testing.T) {
 		{
 			key:           "test_trans",
 			trans:         "{0}{1}",
-			expected:      &ErrConflictingTranslation{key: "test_trans", text: "{0}{1}"},
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: "test_trans", text: "{0}{1}"},
 			expectedError: true,
 		},
 		{
 			key:           -1,
 			trans:         "{0}{1}",
-			expected:      &ErrConflictingTranslation{key: -1, text: "{0}{1}"},
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: -1, text: "{0}{1}"},
 			expectedError: true,
 		},
 		{
@@ -163,7 +163,15 @@ func TestCardinalTranslation(t *testing.T) {
 			key:           "cardinal_test",
 			trans:         "You have a day left.",
 			rule:          locales.PluralRuleOne,
-			expected:      &ErrCardinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddCardinal'", paramZero)},
+			expected:      &ErrCardinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddCardinal'. locale: '%s' key: '%v' text: '%s'", paramZero, en.Locale(), "cardinal_test", "You have a day left.")},
+			expectedError: true,
+		},
+		// bad translation
+		{
+			key:           "cardinal_test",
+			trans:         "You have a day left few.",
+			rule:          locales.PluralRuleFew,
+			expected:      &ErrCardinalTranslation{text: fmt.Sprintf("error: cardinal plural rule '%s' does not exist for locale '%s' key: '%s' text: '%s'", locales.PluralRuleFew, en.Locale(), "cardinal_test", "You have a day left few.")},
 			expectedError: true,
 		},
 		{
@@ -182,7 +190,7 @@ func TestCardinalTranslation(t *testing.T) {
 			key:           "cardinal_test",
 			trans:         "You have {0} days left.",
 			rule:          locales.PluralRuleOther,
-			expected:      &ErrConflictingTranslation{key: "cardinal_test", rule: locales.PluralRuleOther, text: "You have {0} days left."},
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: "cardinal_test", rule: locales.PluralRuleOther, text: "You have {0} days left."},
 			expectedError: true,
 		},
 		{
@@ -267,12 +275,20 @@ func TestOrdinalTranslation(t *testing.T) {
 			key:           "day",
 			trans:         "st",
 			rule:          locales.PluralRuleOne,
-			expected:      &ErrOrdinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddOrdinal'", paramZero)},
+			expected:      &ErrOrdinalTranslation{text: fmt.Sprintf("error: parameter '%s' not found, may want to use 'Add' instead of 'AddOrdinal'. locale: '%s' key: '%v' text: '%s'", paramZero, en.Locale(), "day", "st")},
+			expectedError: true,
+		},
+		// bad translation
+		{
+			key:           "day",
+			trans:         "st",
+			rule:          locales.PluralRuleMany,
+			expected:      &ErrOrdinalTranslation{text: fmt.Sprintf("error: ordinal plural rule '%s' does not exist for locale '%s' key: '%s' text: '%s'", locales.PluralRuleMany, en.Locale(), "day", "st")},
 			expectedError: true,
 		},
 		{
 			key:      "day",
-			trans:    "{0}sfefewt",
+			trans:    "{0}st",
 			rule:     locales.PluralRuleOne,
 			expected: nil,
 		},
@@ -299,7 +315,7 @@ func TestOrdinalTranslation(t *testing.T) {
 			key:           "day",
 			trans:         "{0}th",
 			rule:          locales.PluralRuleOther,
-			expected:      &ErrConflictingTranslation{key: "day", rule: locales.PluralRuleOther, text: "{0}th"},
+			expected:      &ErrConflictingTranslation{locale: en.Locale(), key: "day", rule: locales.PluralRuleOther, text: "{0}th"},
 			expectedError: true,
 		},
 		{
@@ -414,7 +430,15 @@ func TestRangeTranslation(t *testing.T) {
 			key:           "day",
 			trans:         "er -{1} dag vertrokken",
 			rule:          locales.PluralRuleOne,
-			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, are you sure you're adding a Range Translation?", paramZero)},
+			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, are you sure you're adding a Range Translation? locale: '%s' key: '%s' text: '%s'", paramZero, nl.Locale(), "day", "er -{1} dag vertrokken")},
+			expectedError: true,
+		},
+		// bad translation
+		{
+			key:           "day",
+			trans:         "er {0}- dag vertrokken",
+			rule:          locales.PluralRuleMany,
+			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: range plural rule '%s' does not exist for locale '%s' key: '%s' text: '%s'", locales.PluralRuleMany, nl.Locale(), "day", "er {0}- dag vertrokken")},
 			expectedError: true,
 		},
 		// bad translation
@@ -422,7 +446,7 @@ func TestRangeTranslation(t *testing.T) {
 			key:           "day",
 			trans:         "er {0}- dag vertrokken",
 			rule:          locales.PluralRuleOne,
-			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, a Range Translation requires two parameters", paramOne)},
+			expected:      &ErrRangeTranslation{text: fmt.Sprintf("error: parameter '%s' not found, a Range Translation requires two parameters. locale: '%s' key: '%s' text: '%s'", paramOne, nl.Locale(), "day", "er {0}- dag vertrokken")},
 			expectedError: true,
 		},
 		{
@@ -442,7 +466,7 @@ func TestRangeTranslation(t *testing.T) {
 			key:           "day",
 			trans:         "er zijn {0}-{1} dagen over",
 			rule:          locales.PluralRuleOther,
-			expected:      &ErrConflictingTranslation{key: "day", rule: locales.PluralRuleOther, text: "er zijn {0}-{1} dagen over"},
+			expected:      &ErrConflictingTranslation{locale: nl.Locale(), key: "day", rule: locales.PluralRuleOther, text: "er zijn {0}-{1} dagen over"},
 			expectedError: true,
 		},
 		{
@@ -629,7 +653,7 @@ func TestVerifyTranslations(t *testing.T) {
 	}
 
 	// fail cardinal rules
-	expected := &ErrMissingPluralTranslation{translationType: "plural", rule: locales.PluralRuleOther, key: "day"}
+	expected := &ErrMissingPluralTranslation{locale: loc.Locale(), translationType: "plural", rule: locales.PluralRuleOther, key: "day"}
 	err = loc.VerifyTranslations()
 	if err == nil || err.Error() != expected.Error() {
 		t.Errorf("Expected '%s' Got '%s'", expected, err)
@@ -653,7 +677,7 @@ func TestVerifyTranslations(t *testing.T) {
 	}
 
 	// fail range rules
-	expected = &ErrMissingPluralTranslation{translationType: "range", rule: locales.PluralRuleOne, key: "day"}
+	expected = &ErrMissingPluralTranslation{locale: loc.Locale(), translationType: "range", rule: locales.PluralRuleOne, key: "day"}
 	err = loc.VerifyTranslations()
 	if err == nil || err.Error() != expected.Error() {
 		t.Errorf("Expected '%s' Got '%s'", expected, err)
@@ -700,7 +724,7 @@ func TestVerifyTranslations(t *testing.T) {
 	}
 
 	// fail ordinal rules
-	expected = &ErrMissingPluralTranslation{translationType: "ordinal", rule: locales.PluralRuleTwo, key: "day"}
+	expected = &ErrMissingPluralTranslation{locale: loc.Locale(), translationType: "ordinal", rule: locales.PluralRuleTwo, key: "day"}
 	err = loc.VerifyTranslations()
 	if err == nil || err.Error() != expected.Error() {
 		t.Errorf("Expected '%s' Got '%s'", expected, err)
@@ -738,7 +762,7 @@ func TestVerifyTranslationsWithNonStringKeys(t *testing.T) {
 	}
 
 	// fail cardinal rules
-	expected := &ErrMissingPluralTranslation{translationType: "plural", rule: locales.PluralRuleOther, key: -1}
+	expected := &ErrMissingPluralTranslation{locale: loc.Locale(), translationType: "plural", rule: locales.PluralRuleOther, key: -1}
 	err = loc.VerifyTranslations()
 	if err == nil || err.Error() != expected.Error() {
 		t.Errorf("Expected '%s' Got '%s'", expected, err)
@@ -761,3 +785,74 @@ func TestGetFallback(t *testing.T) {
 		t.Errorf("Expected '%s' Got '%s'", expected, trans.Locale())
 	}
 }
+
+func TestVerifyUTTranslations(t *testing.T) {
+
+	e := en.New()
+	uni := New(e, e)
+	en, found := uni.GetTranslator("en")
+	if !found {
+		t.Fatalf("Expected '%t' Got '%t'", true, found)
+	}
+
+	translations := []struct {
+		key           interface{}
+		trans         string
+		rule          locales.PluralRule
+		expected      error
+		expectedError bool
+		override      bool
+	}{
+		{
+			key:      "day",
+			trans:    "{0}st",
+			rule:     locales.PluralRuleOne,
+			expected: nil,
+		},
+		{
+			key:      "day",
+			trans:    "{0}nd",
+			rule:     locales.PluralRuleTwo,
+			expected: nil,
+		},
+		{
+			key:      "day",
+			trans:    "{0}rd",
+			rule:     locales.PluralRuleFew,
+			expected: nil,
+		},
+		// intentionally leaving out plural other
+		// {
+		// 	key:      "day",
+		// 	trans:    "{0}th",
+		// 	rule:     locales.PluralRuleOther,
+		// 	expected: nil,
+		// },
+	}
+
+	for _, tt := range translations {
+
+		err := en.AddOrdinal(tt.key, tt.trans, tt.rule, tt.override)
+		if err != tt.expected {
+			if !tt.expectedError || err.Error() != tt.expected.Error() {
+				t.Errorf("Expected '<nil>' Got '%s'", err)
+			}
+		}
+	}
+
+	expected := "error: missing 'ordinal' plural rule 'Other' for translation with key 'day' and locale 'en'"
+	err := uni.VerifyTranslations()
+	if err == nil || err.Error() != expected {
+		t.Fatalf("Expected '%s' Got '%s'", expected, err)
+	}
+
+	err = en.AddOrdinal("day", "{0}th", locales.PluralRuleOther, false)
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+
+	err = uni.VerifyTranslations()
+	if err != nil {
+		t.Fatalf("Expected '%v' Got '%s'", nil, err)
+	}
+}

+ 14 - 0
universal-translator.go → universal_translator.go

@@ -97,3 +97,17 @@ func (t *UniversalTranslator) AddTranslator(translator locales.Translator, overr
 
 	return nil
 }
+
+// VerifyTranslations runs through all locales and identifies any issues
+// eg. missing plural rules for a locale
+func (t *UniversalTranslator) VerifyTranslations() (err error) {
+
+	for _, trans := range t.translators {
+		err = trans.VerifyTranslations()
+		if err != nil {
+			return
+		}
+	}
+
+	return
+}