Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move some locales code from goflow/envs #96

Merged
merged 1 commit into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.9
github.com/nyaruka/librato v1.0.0
github.com/nyaruka/null/v2 v2.0.3
github.com/nyaruka/phonenumbers v1.1.8
github.com/pkg/errors v0.9.1
github.com/shopspring/decimal v1.3.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRU
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0=
github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg=
github.com/nyaruka/null/v2 v2.0.3 h1:rdmMRQyVzrOF3Jff/gpU/7BDR9mQX0lcLl4yImsA3kw=
github.com/nyaruka/null/v2 v2.0.3/go.mod h1:OCVeCkCXwrg5/qE6RU0c1oUVZBy+ZDrT+xYg1XSaIWA=
github.com/nyaruka/phonenumbers v1.1.8 h1:mjFu85FeoH2Wy18aOMUvxqi1GgAqiQSJsa/cCC5yu2s=
github.com/nyaruka/phonenumbers v1.1.8/go.mod h1:DC7jZd321FqUe+qWSNcHi10tyIyGNXGcNbfkPvdp1Vs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
Expand Down
29 changes: 29 additions & 0 deletions i18n/country.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package i18n

import (
"database/sql/driver"

"github.com/nyaruka/null/v2"
"github.com/nyaruka/phonenumbers"
)

// Country is a ISO 3166-1 alpha-2 country code
type Country string

// NilCountry represents our nil, or unknown country
var NilCountry = Country("")

// DeriveCountryFromTel attempts to derive a country code (e.g. RW) from a phone number
func DeriveCountryFromTel(number string) Country {
parsed, err := phonenumbers.Parse(number, "")
if err != nil {
return ""
}
return Country(phonenumbers.GetRegionCodeForNumber(parsed))
}

// Place nicely with NULLs if persisting to a database or JSON
func (c *Country) Scan(value any) error { return null.ScanString(value, c) }
func (c Country) Value() (driver.Value, error) { return null.StringValue(c) }
func (c Country) MarshalJSON() ([]byte, error) { return null.MarshalString(c) }
func (c *Country) UnmarshalJSON(b []byte) error { return null.UnmarshalString(b, c) }

Check warning on line 29 in i18n/country.go

View check run for this annotation

Codecov / codecov/patch

i18n/country.go#L28-L29

Added lines #L28 - L29 were not covered by tests
29 changes: 29 additions & 0 deletions i18n/country_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package i18n_test

import (
"testing"

"github.com/nyaruka/gocommon/i18n"
"github.com/stretchr/testify/assert"
)

func TestDeriveCountryFromTel(t *testing.T) {
assert.Equal(t, i18n.Country("RW"), i18n.DeriveCountryFromTel("+250788383383"))
assert.Equal(t, i18n.Country("EC"), i18n.DeriveCountryFromTel("+593979000000"))
assert.Equal(t, i18n.NilCountry, i18n.DeriveCountryFromTel("1234"))

v, err := i18n.Country("RW").Value()
assert.NoError(t, err)
assert.Equal(t, "RW", v)

v, err = i18n.NilCountry.Value()
assert.NoError(t, err)
assert.Nil(t, v)

var c i18n.Country
assert.NoError(t, c.Scan("RW"))
assert.Equal(t, i18n.Country("RW"), c)

assert.NoError(t, c.Scan(nil))
assert.Equal(t, i18n.NilCountry, c)
}
36 changes: 36 additions & 0 deletions i18n/language._test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package i18n_test

import (
"testing"

"github.com/nyaruka/gocommon/i18n"

"github.com/stretchr/testify/assert"
)

func TestLanguage(t *testing.T) {
lang, err := i18n.ParseLanguage("ENG")
assert.NoError(t, err)
assert.Equal(t, i18n.Language("eng"), lang)

_, err = i18n.ParseLanguage("base")
assert.EqualError(t, err, "iso-639-3 codes must be 3 characters, got: base")

_, err = i18n.ParseLanguage("xzx")
assert.EqualError(t, err, "unrecognized language code: xzx")

v, err := i18n.Language("eng").Value()
assert.NoError(t, err)
assert.Equal(t, "eng", v)

v, err = i18n.NilLanguage.Value()
assert.NoError(t, err)
assert.Nil(t, v)

var l i18n.Language
assert.NoError(t, l.Scan("eng"))
assert.Equal(t, i18n.Language("eng"), l)

assert.NoError(t, l.Scan(nil))
assert.Equal(t, i18n.NilLanguage, l)
}
50 changes: 50 additions & 0 deletions i18n/language.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package i18n

import (
"database/sql/driver"

"github.com/nyaruka/null/v2"
"github.com/pkg/errors"
"golang.org/x/text/language"
)

// Language is holds a an ISO-639-3 language code.
type Language string

// ISO639_1 returns the ISO-639-1 2-letter code for this language if it has one
func (l Language) ISO639_1() string {
base, err := language.ParseBase(string(l))
if err != nil {
return ""
}
code := base.String()

// not all languages have a 2-letter code
if len(code) != 2 {
return ""
}
return code

Check warning on line 26 in i18n/language.go

View check run for this annotation

Codecov / codecov/patch

i18n/language.go#L15-L26

Added lines #L15 - L26 were not covered by tests
}

// NilLanguage represents our nil, or unknown language
var NilLanguage = Language("")

// ParseLanguage returns a new Language for the passed in language string, or an error if not found
func ParseLanguage(lang string) (Language, error) {
if len(lang) != 3 {
return NilLanguage, errors.Errorf("iso-639-3 codes must be 3 characters, got: %s", lang)
}

base, err := language.ParseBase(lang)
if err != nil {
return NilLanguage, errors.Errorf("unrecognized language code: %s", lang)
}

return Language(base.ISO3()), nil
}

// Place nicely with NULLs if persisting to a database or JSON
func (l *Language) Scan(value any) error { return null.ScanString(value, l) }
func (l Language) Value() (driver.Value, error) { return null.StringValue(l) }
func (l Language) MarshalJSON() ([]byte, error) { return null.MarshalString(l) }
func (l *Language) UnmarshalJSON(b []byte) error { return null.UnmarshalString(b, l) }

Check warning on line 50 in i18n/language.go

View check run for this annotation

Codecov / codecov/patch

i18n/language.go#L49-L50

Added lines #L49 - L50 were not covered by tests
79 changes: 79 additions & 0 deletions i18n/locale.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package i18n

import (
"database/sql/driver"
"fmt"
"strings"

"github.com/nyaruka/null/v2"
"golang.org/x/text/language"
)

// Locale is the combination of a language and optional country, e.g. US English, Brazilian Portuguese, encoded as the
// language code followed by the country code, e.g. eng-US, por-BR. Every locale is valid BCP47 language tag, tho not
// every BCP47 language tag is a valid goflow locale because we only use ISO-639-3 3 letter codes to represent language.
type Locale string

// NewLocale creates a new locale
func NewLocale(l Language, c Country) Locale {
if l == NilLanguage {
return NilLocale
}
if c == NilCountry {
return Locale(l) // e.g. "eng", "por"
}
return Locale(fmt.Sprintf("%s-%s", l, c)) // e.g. "eng-US", "por-BR"
}

func (l Locale) Split() (Language, Country) {
if l == NilLocale || len(l) < 3 {
return NilLanguage, NilCountry
}

parts := strings.SplitN(string(l), "-", 2)
lang := Language(parts[0])
country := NilCountry
if len(parts) > 1 {
country = Country(parts[1])
}

return lang, country
}

func (l Locale) tag() language.Tag {
return language.MustParse(string(l))

Check warning on line 44 in i18n/locale.go

View check run for this annotation

Codecov / codecov/patch

i18n/locale.go#L43-L44

Added lines #L43 - L44 were not covered by tests
}

var NilLocale = Locale("")

// Place nicely with NULLs if persisting to a database or JSON
func (l *Locale) Scan(value any) error { return null.ScanString(value, l) }
func (l Locale) Value() (driver.Value, error) { return null.StringValue(l) }
func (l Locale) MarshalJSON() ([]byte, error) { return null.MarshalString(l) }
func (l *Locale) UnmarshalJSON(b []byte) error { return null.UnmarshalString(b, l) }

Check warning on line 53 in i18n/locale.go

View check run for this annotation

Codecov / codecov/patch

i18n/locale.go#L52-L53

Added lines #L52 - L53 were not covered by tests

// BCP47Matcher helps find best matching locale from a set of available locales
type BCP47Matcher struct {
codes []string
matcher language.Matcher
}

// NewBCP47Matcher creates a new BCP47 matcher from the set of available locales which must be valid BCP47 tags.
func NewBCP47Matcher(codes ...string) *BCP47Matcher {
tags := make([]language.Tag, len(codes))
for i := range codes {
tags[i] = language.MustParse(codes[i])
}
return &BCP47Matcher{codes: codes, matcher: language.NewMatcher(tags)}

Check warning on line 67 in i18n/locale.go

View check run for this annotation

Codecov / codecov/patch

i18n/locale.go#L62-L67

Added lines #L62 - L67 were not covered by tests
}

func (m *BCP47Matcher) ForLocales(preferred ...Locale) string {
prefTags := make([]language.Tag, len(preferred))
for i := range preferred {
prefTags[i] = preferred[i].tag()
}

Check warning on line 74 in i18n/locale.go

View check run for this annotation

Codecov / codecov/patch

i18n/locale.go#L70-L74

Added lines #L70 - L74 were not covered by tests

// see https://github.com/golang/go/issues/24211
_, idx, _ := m.matcher.Match(prefTags...)
return m.codes[idx]

Check warning on line 78 in i18n/locale.go

View check run for this annotation

Codecov / codecov/patch

i18n/locale.go#L77-L78

Added lines #L77 - L78 were not covered by tests
}
62 changes: 62 additions & 0 deletions i18n/locale_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package i18n_test

import (
"testing"

"github.com/nyaruka/gocommon/i18n"
"github.com/stretchr/testify/assert"
)

func TestLocale(t *testing.T) {
assert.Equal(t, i18n.Locale(""), i18n.NewLocale("", ""))
assert.Equal(t, i18n.Locale(""), i18n.NewLocale("", "US")) // invalid without language
assert.Equal(t, i18n.Locale("eng"), i18n.NewLocale("eng", "")) // valid without country
assert.Equal(t, i18n.Locale("eng-US"), i18n.NewLocale("eng", "US"))

l, c := i18n.Locale("eng-US").Split()
assert.Equal(t, i18n.Language("eng"), l)
assert.Equal(t, i18n.Country("US"), c)

l, c = i18n.NilLocale.Split()
assert.Equal(t, i18n.NilLanguage, l)
assert.Equal(t, i18n.NilCountry, c)

v, err := i18n.NewLocale("eng", "US").Value()
assert.NoError(t, err)
assert.Equal(t, "eng-US", v)

v, err = i18n.NilLanguage.Value()
assert.NoError(t, err)
assert.Nil(t, v)

var lc i18n.Locale
assert.NoError(t, lc.Scan("eng-US"))
assert.Equal(t, i18n.Locale("eng-US"), lc)

assert.NoError(t, lc.Scan(nil))
assert.Equal(t, i18n.NilLocale, lc)
}

func TesBCP47Matcher(t *testing.T) {
tests := []struct {
preferred []i18n.Locale
available []string
best string
}{
{preferred: []i18n.Locale{"eng-US"}, available: []string{"es_EC", "en-US"}, best: "en-US"},
{preferred: []i18n.Locale{"eng-US"}, available: []string{"es", "en"}, best: "en"},
{preferred: []i18n.Locale{"eng"}, available: []string{"es-US", "en-UK"}, best: "en-UK"},
{preferred: []i18n.Locale{"eng", "fra"}, available: []string{"fr-CA", "en-RW"}, best: "en-RW"},
{preferred: []i18n.Locale{"eng", "fra"}, available: []string{"fra-CA", "eng-RW"}, best: "eng-RW"},
{preferred: []i18n.Locale{"fra", "eng"}, available: []string{"fra-CA", "eng-RW"}, best: "fra-CA"},
{preferred: []i18n.Locale{"spa"}, available: []string{"es-EC", "es-MX", "es-ES"}, best: "es-ES"},
{preferred: []i18n.Locale{}, available: []string{"es_EC", "en-US"}, best: "es_EC"},
}

for _, tc := range tests {
m := i18n.NewBCP47Matcher(tc.available...)
best := m.ForLocales(tc.preferred...)

assert.Equal(t, tc.best, best, "locale mismatch for preferred=%v available=%s", tc.preferred, tc.available)
}
}
Loading