langs/i18n: Revise the plural implementation
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 22 Apr 2021 07:57:24 +0000 (09:57 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 23 Apr 2021 05:39:59 +0000 (07:39 +0200)
There were some issues introduced with the plural counting when we upgraded from v1 to v2 of go-i18n.

This commit improves that situation given the following rules:

* A single integer argument is used as plural count and passed to the i18n template as a int type with a `.Count` method. The latter is to preserve compability with v1.
* Else the plural count is either fetched from the `Count`/`count` field/method/map or from the value itself.
* Any data type is accepted, if it can be converted to an integer, that value is used.

The above means that you can now do pass a single integer and both of the below will work:

```
{{ . }} minutes to read
{{ .Count }} minutes to read
```

Fixes #8454
Closes #7822
See https://github.com/gohugoio/hugoDocs/issues/1410

langs/i18n/i18n.go
langs/i18n/i18n_test.go

index dab620be60ab44a3b67a448f1efb8dcac90cb6ac..17462bc560cb1eb6cd15a4dcaa235955f29f37a6 100644 (file)
@@ -17,6 +17,8 @@ import (
        "reflect"
        "strings"
 
+       "github.com/spf13/cast"
+
        "github.com/gohugoio/hugo/common/hreflect"
        "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/config"
@@ -69,17 +71,15 @@ func (t Translator) initFuncs(bndl *i18n.Bundle) {
                currentLangKey := strings.ToLower(strings.TrimPrefix(currentLangStr, artificialLangTagPrefix))
                localizer := i18n.NewLocalizer(bndl, currentLangStr)
                t.translateFuncs[currentLangKey] = func(translationID string, templateData interface{}) string {
-                       var pluralCount interface{}
+                       pluralCount := getPluralCount(templateData)
 
                        if templateData != nil {
                                tp := reflect.TypeOf(templateData)
-                               if hreflect.IsNumber(tp.Kind()) {
-                                       pluralCount = templateData
-                                       // This was how go-i18n worked in v1.
-                                       templateData = map[string]interface{}{
-                                               "Count": templateData,
-                                       }
-
+                               if hreflect.IsInt(tp.Kind()) {
+                                       // This was how go-i18n worked in v1,
+                                       // and we keep it like this to avoid breaking
+                                       // lots of sites in the wild.
+                                       templateData = intCount(cast.ToInt(templateData))
                                }
                        }
 
@@ -109,3 +109,49 @@ func (t Translator) initFuncs(bndl *i18n.Bundle) {
                }
        }
 }
+
+// intCount wraps the Count method.
+type intCount int
+
+func (c intCount) Count() int {
+       return int(c)
+}
+
+const countFieldName = "Count"
+
+func getPluralCount(o interface{}) int {
+       if o == nil {
+               return 0
+       }
+
+       switch v := o.(type) {
+       case map[string]interface{}:
+               for k, vv := range v {
+                       if strings.EqualFold(k, countFieldName) {
+                               return cast.ToInt(vv)
+                       }
+               }
+       default:
+               vv := reflect.Indirect(reflect.ValueOf(v))
+               if vv.Kind() == reflect.Interface && !vv.IsNil() {
+                       vv = vv.Elem()
+               }
+               tp := vv.Type()
+
+               if tp.Kind() == reflect.Struct {
+                       f := vv.FieldByName(countFieldName)
+                       if f.IsValid() {
+                               return cast.ToInt(f.Interface())
+                       }
+                       m := vv.MethodByName(countFieldName)
+                       if m.IsValid() && m.Type().NumIn() == 0 && m.Type().NumOut() == 1 {
+                               c := m.Call(nil)
+                               return cast.ToInt(c[0].Interface())
+                       }
+               }
+
+               return cast.ToInt(o)
+       }
+
+       return 0
+}
index 7b5a10d675df27353eed7adfba09bf43a498dc92..8a2335c92be341eb6aa63533d3d007088eec4d75 100644 (file)
@@ -141,6 +141,20 @@ other = "{{ .Count }} minutes to read"
                expected:     "One minute to read",
                expectedFlag: "One minute to read",
        },
+       {
+               name: "readingTime-many-dot",
+               data: map[string][]byte{
+                       "en.toml": []byte(`[readingTime]
+one = "One minute to read"
+other = "{{ . }} minutes to read"
+`),
+               },
+               args:         21,
+               lang:         "en",
+               id:           "readingTime",
+               expected:     "21 minutes to read",
+               expectedFlag: "21 minutes to read",
+       },
        {
                name: "readingTime-many",
                data: map[string][]byte{
@@ -155,6 +169,62 @@ other = "{{ .Count }} minutes to read"
                expected:     "21 minutes to read",
                expectedFlag: "21 minutes to read",
        },
+       // Issue #8454
+       {
+               name: "readingTime-map-one",
+               data: map[string][]byte{
+                       "en.toml": []byte(`[readingTime]
+one = "One minute to read"
+other = "{{ .Count }} minutes to read"
+`),
+               },
+               args:         map[string]interface{}{"Count": 1},
+               lang:         "en",
+               id:           "readingTime",
+               expected:     "One minute to read",
+               expectedFlag: "One minute to read",
+       },
+       {
+               name: "readingTime-string-one",
+               data: map[string][]byte{
+                       "en.toml": []byte(`[readingTime]
+one = "One minute to read"
+other = "{{ . }} minutes to read"
+`),
+               },
+               args:         "1",
+               lang:         "en",
+               id:           "readingTime",
+               expected:     "One minute to read",
+               expectedFlag: "One minute to read",
+       },
+       {
+               name: "readingTime-map-many",
+               data: map[string][]byte{
+                       "en.toml": []byte(`[readingTime]
+one = "One minute to read"
+other = "{{ .Count }} minutes to read"
+`),
+               },
+               args:         map[string]interface{}{"Count": 21},
+               lang:         "en",
+               id:           "readingTime",
+               expected:     "21 minutes to read",
+               expectedFlag: "21 minutes to read",
+       },
+       {
+               name: "argument-float",
+               data: map[string][]byte{
+                       "en.toml": []byte(`[float]
+other = "Number is {{ . }}"
+`),
+               },
+               args:         22.5,
+               lang:         "en",
+               id:           "float",
+               expected:     "Number is 22.5",
+               expectedFlag: "Number is 22.5",
+       },
        // Same id and translation in current language
        // https://github.com/gohugoio/hugo/issues/2607
        {
@@ -246,6 +316,46 @@ func doTestI18nTranslate(t testing.TB, test i18nTest, cfg config.Provider) strin
        return f(test.id, test.args)
 }
 
+type countField struct {
+       Count int
+}
+
+type noCountField struct {
+       Counts int
+}
+
+type countMethod struct {
+}
+
+func (c countMethod) Count() int {
+       return 32
+}
+
+func TestGetPluralCount(t *testing.T) {
+       c := qt.New(t)
+
+       c.Assert(getPluralCount(map[string]interface{}{"Count": 32}), qt.Equals, 32)
+       c.Assert(getPluralCount(map[string]interface{}{"Count": 1}), qt.Equals, 1)
+       c.Assert(getPluralCount(map[string]interface{}{"Count": "32"}), qt.Equals, 32)
+       c.Assert(getPluralCount(map[string]interface{}{"count": 32}), qt.Equals, 32)
+       c.Assert(getPluralCount(map[string]interface{}{"Count": "32"}), qt.Equals, 32)
+       c.Assert(getPluralCount(map[string]interface{}{"Counts": 32}), qt.Equals, 0)
+       c.Assert(getPluralCount("foo"), qt.Equals, 0)
+       c.Assert(getPluralCount(countField{Count: 22}), qt.Equals, 22)
+       c.Assert(getPluralCount(&countField{Count: 22}), qt.Equals, 22)
+       c.Assert(getPluralCount(noCountField{Counts: 23}), qt.Equals, 0)
+       c.Assert(getPluralCount(countMethod{}), qt.Equals, 32)
+       c.Assert(getPluralCount(&countMethod{}), qt.Equals, 32)
+
+       c.Assert(getPluralCount(1234), qt.Equals, 1234)
+       c.Assert(getPluralCount(1234.4), qt.Equals, 1234)
+       c.Assert(getPluralCount(1234.6), qt.Equals, 1234)
+       c.Assert(getPluralCount(0.6), qt.Equals, 0)
+       c.Assert(getPluralCount(1.0), qt.Equals, 1)
+       c.Assert(getPluralCount("1234"), qt.Equals, 1234)
+       c.Assert(getPluralCount(nil), qt.Equals, 0)
+}
+
 func prepareTranslationProvider(t testing.TB, test i18nTest, cfg config.Provider) *TranslationProvider {
        c := qt.New(t)
        fs := hugofs.NewMem(cfg)