(Feat): Initial Commit
This commit is contained in:
211
internal/i18n/i18n.go
Normal file
211
internal/i18n/i18n.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// i18n, we expect the following:
|
||||
// Your translation toml files should live within /web/i18n and are named according to their supported locale e.g. en.toml, de.toml or en-uk.toml, en-us.toml.
|
||||
//
|
||||
// All translation files should hold the same keys (there are no unique keys on a single bundle locale, apart from pluralization).
|
||||
//
|
||||
// The Service object is created and owned by the api.Server (s.I18n), you typically don't want to create your own object.
|
||||
//
|
||||
// Pluralization obeys to CLDR rules (https://cldr.unicode.org/index/cldr-spec/plural-rules).
|
||||
// Some keywords are reserved for CLDR behaviour, templating and documentation: id, description, hash, leftdelim, rightdelim, zero, one, two, few, many, other
|
||||
// See
|
||||
|
||||
// Service is your convenience object to call Translate/TranslatePlural and match languages according to your loaded translation bundle and its supported languages/locales.
|
||||
type Service struct {
|
||||
bundle *i18n.Bundle
|
||||
matcher language.Matcher
|
||||
}
|
||||
|
||||
// Data should be used to pass your template data
|
||||
type Data map[string]string
|
||||
|
||||
// New returns a new Service struct holding bundle and matcher with the settings of the given config
|
||||
//
|
||||
// Note that Service is typically created and owned by the api.Server (use it via s.I18n)
|
||||
func New(config config.I18n) (*Service, error) {
|
||||
bundle := i18n.NewBundle(config.DefaultLanguage)
|
||||
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||
|
||||
// Load all translation files in each language...
|
||||
files, err := os.ReadDir(config.BundleDirAbs)
|
||||
if err != nil {
|
||||
log.Err(err).Str("dir", config.BundleDirAbs).Msg("Failed to read i18n bundle directory")
|
||||
return nil, fmt.Errorf("failed to read i18n bundle directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
|
||||
// bundle.LoadMessageFile automatically guesses the language.Tag based on the filenames it encounters
|
||||
_, err := bundle.LoadMessageFile(filepath.Join(config.BundleDirAbs, file.Name()))
|
||||
if err != nil {
|
||||
log.Err(err).Str("file", file.Name()).Msg("Failed to load i18n message file")
|
||||
return nil, fmt.Errorf("failed to load i18n message file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tags := bundle.LanguageTags()
|
||||
|
||||
for tagIndex, tag := range tags {
|
||||
// Undetermined languages are disallowed in our bundle.
|
||||
if tag == language.Und {
|
||||
err := fmt.Errorf("undetermined language at index %v in i18n message bundle: %v", tagIndex, tags)
|
||||
log.Err(err).Int("index", tagIndex).Str("tags", fmt.Sprintf("%v", tags)).Msg("Invalid i18n message bundle or default language.")
|
||||
return nil, fmt.Errorf("invalid i18n message bundle or default language: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Service{
|
||||
bundle: bundle,
|
||||
matcher: language.NewMatcher(tags),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Translate your key into a localized string.
|
||||
//
|
||||
// Translate makes a lookup for the key in the current bundle with the specified language.
|
||||
// If a language translation is not available the default language will be used.
|
||||
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
|
||||
//
|
||||
// Translate will not fail if a template value is missing "<no value>" will be inserted instead.
|
||||
// Translate will also not fail if the key is not present. "{{key}}" will be returned instead.
|
||||
func (m *Service) Translate(key string, lang language.Tag, data ...Data) string {
|
||||
msg, err := m.TranslateMaybe(key, lang, data...)
|
||||
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("key", key).Str("lang", lang.String()).Msg("Failed to translate")
|
||||
return key
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// TranslateMaybe has the same sematics as Translate with the following exceptions:
|
||||
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
|
||||
//
|
||||
// This method may be useful for conditional translation rendering (if key is available, use that, else...).
|
||||
func (m *Service) TranslateMaybe(key string, lang language.Tag, data ...Data) (string, error) {
|
||||
localizeConfig := &i18n.LocalizeConfig{
|
||||
MessageID: key,
|
||||
}
|
||||
|
||||
if len(data) > 0 {
|
||||
localizeConfig.TemplateData = data[0]
|
||||
}
|
||||
|
||||
return m.translateConfigurable(lang, localizeConfig)
|
||||
}
|
||||
|
||||
// TranslatePlural translates a pluralized cldrKey into a localized string.
|
||||
//
|
||||
// TranslatePlural makes a lookup for the cldrKey (a base key holding CLDR keys like "one" and "other") in the current bundle with the specified language.
|
||||
// This function should be used to conditionally show the pluralized form, controlled by the count param and according to the CLDR rules.
|
||||
//
|
||||
// Note that English and German only support .one and .other CLDR plural rules.
|
||||
// See https://cldr.unicode.org/index/cldr-spec/plural-rules and https://www.unicode.org/cldr/cldr-aux/charts/28/supplemental/language_plural_rules.html
|
||||
//
|
||||
// If a language translation is not available the default language will be used.
|
||||
// Additional data for templated strings can be passed as key value pairs with by passing an optional data map.
|
||||
// The count param is automatically injected into this data map as stringified {{.Count}} and may be overwritten.
|
||||
//
|
||||
// TranslatePlural will not fail if a template value is missing "<no value>" will be inserted instead.
|
||||
// TranslatePlural will also not fail if the cldrKey is not present. "{{cldrKey}} (count={{count}})" will be returned instead.
|
||||
func (m *Service) TranslatePlural(cldrKey string, count interface{}, lang language.Tag, data ...Data) string {
|
||||
msg, err := m.TranslatePluralMaybe(cldrKey, count, lang, data...)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("count", fmt.Sprintf("%v", count)).Str("cldrKey", cldrKey).Str("lang", lang.String()).Msg("Failed to translate plural")
|
||||
return fmt.Sprintf("%s (count=%v)", cldrKey, count)
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// TranslatePluralMaybe uses the same sematics as TranslatePlural with the following exceptions:
|
||||
// It exposes encountered errors (does not automatically log this error) and encountered errors may result in an empty "" string!
|
||||
//
|
||||
// This method may be useful for conditional plural translation rendering (if key is available, use that, else...).
|
||||
func (m *Service) TranslatePluralMaybe(cldrKey string, count interface{}, lang language.Tag, data ...Data) (string, error) {
|
||||
localizeConfig := &i18n.LocalizeConfig{
|
||||
MessageID: cldrKey,
|
||||
PluralCount: count,
|
||||
}
|
||||
|
||||
// We inject Count by default into our template data (for rare usecases you may overwrite it)
|
||||
templateData := make(Data)
|
||||
templateData["Count"] = fmt.Sprintf("%v", count)
|
||||
|
||||
// If optional data was provided, merge them into the templateData map
|
||||
if len(data) > 0 {
|
||||
for k, v := range data[0] {
|
||||
templateData[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
localizeConfig.TemplateData = templateData
|
||||
|
||||
return m.translateConfigurable(lang, localizeConfig)
|
||||
}
|
||||
|
||||
// ParseAcceptLanguage takes the value of the Accept-Language header and returns
|
||||
// the best matched language using the matcher.
|
||||
func (m *Service) ParseAcceptLanguage(lang string) language.Tag {
|
||||
// we deliberately ignore the error returned here, as it will be nil and the matcher will simply pick the default language
|
||||
// this allows us to skip any malformed Accept-Language headers without returning 500 errors to the client
|
||||
// additionally, we don't really care about the q-factor weighting or confidence, the first match will be picked (with a fallback to config.DefaultLanguage)
|
||||
tags, _, err := language.ParseAcceptLanguage(lang)
|
||||
if err != nil {
|
||||
log.Err(err).Str("lang", lang).Msg("Failed to parse accept language")
|
||||
}
|
||||
matchedTag, _, _ := m.matcher.Match(tags...)
|
||||
|
||||
return matchedTag
|
||||
}
|
||||
|
||||
// ParseLang parses the string as language tag and returns
|
||||
// the best matched language using the matcher.
|
||||
func (m *Service) ParseLang(lang string) language.Tag {
|
||||
t, err := language.Parse(lang)
|
||||
if err != nil {
|
||||
log.Err(err).Str("lang", lang).Msg("Failed to parse language")
|
||||
}
|
||||
|
||||
matchedTag, _, _ := m.matcher.Match(t)
|
||||
|
||||
return matchedTag
|
||||
}
|
||||
|
||||
// Tags returns the parsed and priority ordered []language.Tag (your config.DefaultLanguage will be on position 0)
|
||||
func (m *Service) Tags() []language.Tag {
|
||||
return m.bundle.LanguageTags()
|
||||
}
|
||||
|
||||
// translateConfigurable is used internally for fully configurable translations according to our configured language precedence semantics (new Localizer per call).
|
||||
func (m *Service) translateConfigurable(lang language.Tag, localizeConfig *i18n.LocalizeConfig) (string, error) {
|
||||
// We benchmarked precaching all known []i18n.NewLocalizer during initialization,
|
||||
// but it doesn't make a significant difference even with 10000 concurrent * 8 .Translate calls.
|
||||
// Thus we take the easy route and initialize a new localizer with each .Translate or .TranslatePlural call.
|
||||
localizer := i18n.NewLocalizer(m.bundle, lang.String())
|
||||
|
||||
msg, err := localizer.Localize(localizeConfig)
|
||||
if err != nil {
|
||||
return msg, fmt.Errorf("failed to localize: %w", err)
|
||||
}
|
||||
|
||||
return msg, nil
|
||||
}
|
||||
589
internal/i18n/i18n_test.go
Normal file
589
internal/i18n/i18n_test.go
Normal file
@@ -0,0 +1,589 @@
|
||||
package i18n_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/i18n"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestServerProvidedI18n(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
// uses /app/web/i18n by default
|
||||
// expect all i18n files were loaded and the defaultLanguage matches the FIRST priority Tag.
|
||||
assert.Equal(t, s.Config.I18n.DefaultLanguage, s.I18n.Tags()[0])
|
||||
|
||||
// expect all i18ns were loaded...
|
||||
files, err := os.ReadDir(s.Config.I18n.BundleDirAbs)
|
||||
require.NoError(t, err)
|
||||
|
||||
msgFilesCount := 0
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
|
||||
msgFilesCount++
|
||||
}
|
||||
|
||||
if msgFilesCount == 0 {
|
||||
// no i18n bundles were available, as the defaultLanguage is a tag itself, check for len 1
|
||||
assert.Len(t, s.I18n.Tags(), 1)
|
||||
} else {
|
||||
assert.Len(t, s.I18n.Tags(), msgFilesCount)
|
||||
}
|
||||
|
||||
msg := s.I18n.Translate("this.key.will.never.exist", s.Config.I18n.DefaultLanguage)
|
||||
assert.Equal(t, "this.key.will.never.exist", msg)
|
||||
})
|
||||
}
|
||||
|
||||
// Note that all following tests use a special message directory within /internal/i18n/testdata.
|
||||
// We do this to ensure we don't depend on your project specific i18n bundle/configuration,
|
||||
// that you would typically store within /web/i18n.
|
||||
|
||||
func TestI18n(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, language.English, srv.Tags()[0])
|
||||
assert.Equal(t, language.German, srv.Tags()[1])
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
|
||||
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Guten Tag Hans", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
|
||||
assert.Equal(t, "Welcome Franz", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English)
|
||||
assert.Equal(t, "Welcome <no value>", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.German)
|
||||
assert.Equal(t, "Das ist ein Test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.English)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.Spanish)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.English)
|
||||
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
|
||||
|
||||
msg = srv.Translate("Test.Invalid.Key.Does.Not.Exist", language.German)
|
||||
assert.Equal(t, "Test.Invalid.Key.Does.Not.Exist", msg)
|
||||
|
||||
msg = srv.Translate("Test.String.DE.only", language.English)
|
||||
assert.Equal(t, "Test.String.DE.only", msg)
|
||||
|
||||
msg = srv.Translate("Test.String.DE.only", language.German)
|
||||
assert.Equal(t, "This key only exists in DE", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("Test.String.DE.only", language.English)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg) // no fallback!
|
||||
|
||||
msg = srv.Translate("Test.String.EN.only", language.English)
|
||||
assert.Equal(t, "This key only exists in EN", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("Test.String.EN.only", language.German)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "This key only exists in EN", msg) // fallback (but error)
|
||||
|
||||
msg = srv.Translate("Test.String.EN.only", language.German)
|
||||
assert.Equal(t, "Test.String.EN.only", msg)
|
||||
|
||||
msg = srv.Translate("", language.German) // empty key
|
||||
assert.Empty(t, msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("", language.German) // empty key
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg)
|
||||
|
||||
// ensure language subvariants are supported
|
||||
deAt := srv.ParseLang("de-AT")
|
||||
assert.NotEqual(t, language.German, deAt)
|
||||
msg = srv.Translate("Test.Body", deAt)
|
||||
assert.Equal(t, "Das ist ein Test", msg)
|
||||
}
|
||||
|
||||
func TestI18nConcurrentUsage(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(100)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
go func(index int) {
|
||||
msg := srv.Translate("Test.Welcome", language.German, i18n.Data{"Name": strconv.Itoa(index)})
|
||||
assert.Equal(t, fmt.Sprintf("Guten Tag %v", index), msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": strconv.Itoa(index)})
|
||||
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.Spanish, i18n.Data{"Name": strconv.Itoa(index)})
|
||||
assert.Equal(t, fmt.Sprintf("Welcome %v", index), msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English, i18n.Data{"Name": "Franz"})
|
||||
assert.Equal(t, "Welcome Franz", msg)
|
||||
|
||||
msg = srv.Translate("Test.Welcome", language.English)
|
||||
assert.Equal(t, "Welcome <no value>", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.German)
|
||||
assert.Equal(t, "Das ist ein Test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.English)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
|
||||
msg = srv.Translate("Test.Body", language.Spanish)
|
||||
assert.Equal(t, "This is a test", msg)
|
||||
wg.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestI18nOtherDefault(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.German,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, language.German, srv.Tags()[0])
|
||||
assert.Equal(t, language.English, srv.Tags()[1])
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
}
|
||||
|
||||
func TestI18nInexistantDefault(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.Italian,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, language.Italian, srv.Tags()[0])
|
||||
assert.Equal(t, language.German, srv.Tags()[1])
|
||||
assert.Equal(t, language.English, srv.Tags()[2])
|
||||
assert.Len(t, srv.Tags(), 3)
|
||||
}
|
||||
|
||||
func TestI18nEmpty(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.Italian,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-empty"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 1) // the DefaultLanguage should still be set!
|
||||
assert.Equal(t, language.Italian, srv.Tags()[0])
|
||||
|
||||
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
|
||||
assert.Equal(t, language.Italian, tag)
|
||||
|
||||
msg := srv.Translate("no.test.key.exists", tag)
|
||||
assert.Equal(t, "no.test.key.exists", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("no.test.key.exists", tag)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg)
|
||||
|
||||
msg = srv.Translate("no.test.key.exists", language.Ukrainian)
|
||||
assert.Equal(t, "no.test.key.exists", msg)
|
||||
|
||||
msg, err = srv.TranslateMaybe("no.test.key.exists", language.Ukrainian)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg)
|
||||
}
|
||||
|
||||
func TestI18nSpecialized(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.AmericanEnglish,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-specialized"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 4) // specialized subvariant is default
|
||||
assert.Equal(t, language.AmericanEnglish, srv.Tags()[0])
|
||||
|
||||
msg := srv.Translate("test.punchline", language.AmericanEnglish)
|
||||
assert.Equal(t, "I can has HUMOR?", msg)
|
||||
|
||||
msg = srv.Translate("test.punchline", language.BritishEnglish)
|
||||
assert.Equal(t, "I can has HUMOUR?", msg)
|
||||
|
||||
msg = srv.Translate("test.punchline", language.English)
|
||||
assert.Equal(t, "I can has HUMOR?", msg) // fall back to default
|
||||
|
||||
msg = srv.Translate("test.punchline", language.German)
|
||||
assert.Equal(t, "Habe ich Humor?", msg) // jump to parsed Austrian German
|
||||
|
||||
tag := srv.ParseAcceptLanguage("de-at,en-US;q=0.7,en;q=0.3") // explicit Austrian tag
|
||||
msg = srv.Translate("test.punchline", tag)
|
||||
assert.Equal(t, "Koan i Humor?", msg) // jump to parsed Austrian German
|
||||
}
|
||||
|
||||
func TestReservedKeywordsResolve(t *testing.T) {
|
||||
// "reserved" keys:
|
||||
// "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
|
||||
// see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
|
||||
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-reserved"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
|
||||
// single reserved word (not yet mapped, requires 2 keywords at least)
|
||||
msg := srv.Translate("reserved1.zero", language.English)
|
||||
assert.Equal(t, "Zero", msg)
|
||||
|
||||
msg = srv.Translate("reserved2.one", language.English)
|
||||
assert.Equal(t, "One", msg)
|
||||
|
||||
msg = srv.Translate("reserved3.two", language.English)
|
||||
assert.Equal(t, "Two", msg)
|
||||
|
||||
msg = srv.Translate("reserved4.few", language.English)
|
||||
assert.Equal(t, "Few", msg)
|
||||
|
||||
msg = srv.Translate("reserved5.many", language.English)
|
||||
assert.Equal(t, "Many", msg)
|
||||
|
||||
msg = srv.Translate("reserved6.other", language.English)
|
||||
assert.Equal(t, "Other", msg)
|
||||
|
||||
msg = srv.Translate("reserved7.id", language.English)
|
||||
assert.Equal(t, "id", msg)
|
||||
|
||||
msg = srv.Translate("reserved8.description", language.English)
|
||||
assert.Equal(t, "Description", msg)
|
||||
|
||||
// single parent is not directly resolveable
|
||||
msg = srv.Translate("reserved2", language.English)
|
||||
assert.Equal(t, "reserved2", msg)
|
||||
|
||||
// german: single reserved word
|
||||
msg = srv.Translate("reserved1.zero", language.German)
|
||||
assert.Equal(t, "Null", msg)
|
||||
|
||||
msg = srv.Translate("reserved2.one", language.German)
|
||||
assert.Equal(t, "Eins", msg)
|
||||
|
||||
msg = srv.Translate("reserved3.two", language.German)
|
||||
assert.Equal(t, "Zwei", msg)
|
||||
|
||||
msg = srv.Translate("reserved4.few", language.German)
|
||||
assert.Equal(t, "Wenig", msg)
|
||||
|
||||
msg = srv.Translate("reserved5.many", language.German)
|
||||
assert.Equal(t, "Mehr", msg)
|
||||
|
||||
msg = srv.Translate("reserved6.other", language.German)
|
||||
assert.Equal(t, "Andere", msg)
|
||||
|
||||
msg = srv.Translate("reserved7.id", language.German)
|
||||
assert.Equal(t, "ID", msg)
|
||||
|
||||
msg = srv.Translate("reserved8.description", language.German)
|
||||
assert.Equal(t, "Beschreibung", msg)
|
||||
|
||||
// Combined toml map: all reserved words
|
||||
// This does not work as it's parsed as map and CLDR plural rules now apply!
|
||||
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
|
||||
msg = srv.Translate("reservedMap.zero", language.English)
|
||||
assert.Equal(t, "reservedMap.zero", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.one", language.English)
|
||||
assert.Equal(t, "reservedMap.one", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.two", language.English)
|
||||
assert.Equal(t, "reservedMap.two", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.few", language.English)
|
||||
assert.Equal(t, "reservedMap.few", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.many", language.English)
|
||||
assert.Equal(t, "reservedMap.many", msg)
|
||||
|
||||
msg = srv.Translate("reservedMap.other", language.English)
|
||||
assert.Equal(t, "reservedMap.other", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", 0, language.English)
|
||||
assert.Equal(t, "Other", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", 1, language.English)
|
||||
assert.Equal(t, "One", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", 2, language.English)
|
||||
assert.Equal(t, "Other", msg)
|
||||
|
||||
msg = srv.TranslatePlural("reservedMap", "asdfasdf", language.English)
|
||||
assert.Equal(t, "reservedMap (count=asdfasdf)", msg)
|
||||
|
||||
// plain toml: all reserved words
|
||||
// This does not work as it's parsed as map and CLDR plural rules now apply!
|
||||
// The last key is interpreted as https://cldr.unicode.org/index/cldr-spec/plural-rules
|
||||
msg = srv.Translate("reserved.plain.zero", language.English)
|
||||
assert.Equal(t, "reserved.plain.zero", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.one", language.English)
|
||||
assert.Equal(t, "reserved.plain.one", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.two", language.English)
|
||||
assert.Equal(t, "reserved.plain.two", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.few", language.English)
|
||||
assert.Equal(t, "reserved.plain.few", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.many", language.English)
|
||||
assert.Equal(t, "reserved.plain.many", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain.other", language.English)
|
||||
assert.Equal(t, "reserved.plain.other", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain2.id", language.English)
|
||||
assert.Equal(t, "reserved.plain2.id", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain2.description", language.English)
|
||||
assert.Equal(t, "reserved.plain2.description", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain3.id", language.English)
|
||||
assert.Equal(t, "reserved.plain3.id", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain3.description", language.English)
|
||||
assert.Equal(t, "reserved.plain3.description", msg)
|
||||
|
||||
msg = srv.Translate("reserved.plain3.test", language.English)
|
||||
assert.Equal(t, "reserved.plain3.test", msg)
|
||||
}
|
||||
|
||||
func TestI18nPlural(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-plural"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, srv.Tags(), 2)
|
||||
|
||||
msg := srv.TranslatePlural("cats", 0, language.AmericanEnglish)
|
||||
assert.Equal(t, "I've 0 cats.", msg) // zero is not supported for CLDR English, "I don't have a cat." is not possible!
|
||||
|
||||
msg = srv.TranslatePlural("cats", 1, language.BritishEnglish)
|
||||
assert.Equal(t, "I've one cat.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 2, language.English)
|
||||
assert.Equal(t, "I've 2 cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 8, language.English)
|
||||
assert.Equal(t, "I've 8 cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", -1, language.English) // negative and positive scales behave the same!
|
||||
assert.Equal(t, "I've one cat.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", -2, language.English) // negative and positive scales behave the same!
|
||||
assert.Equal(t, "I've -2 cats.", msg)
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("cats", -2, language.English)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "I've -2 cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", nil, language.English)
|
||||
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("cats", nil, language.English)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "I've <nil> cats.", msg) // invalid count
|
||||
|
||||
msg = srv.TranslatePlural("cats", "many", language.English)
|
||||
assert.Equal(t, "cats (count=many)", msg) // internal failed to translate plural!
|
||||
|
||||
// overwrite Count
|
||||
msg = srv.TranslatePlural("cats", 8, language.English, i18n.Data{"Count": "too many"})
|
||||
assert.Equal(t, "I've too many cats.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 0, language.German)
|
||||
assert.Equal(t, "Ich habe 0 Katzen.", msg) // zero is not supported for CLDR German, "Ich habe keine Katze." is not possible!
|
||||
|
||||
msg = srv.TranslatePlural("cats", 1, language.German)
|
||||
assert.Equal(t, "Ich habe eine Katze.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 2, language.German)
|
||||
assert.Equal(t, "Ich habe 2 Katzen.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", 8, language.German)
|
||||
assert.Equal(t, "Ich habe 8 Katzen.", msg)
|
||||
|
||||
msg = srv.TranslatePlural("cats", "viele", language.German)
|
||||
assert.Equal(t, "cats (count=viele)", msg) // internal failed to translate plural!
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("cats", "viele", language.German)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg) // empty string for errors!
|
||||
|
||||
// overwrite Count
|
||||
msg = srv.TranslatePlural("cats", 8, language.German, i18n.Data{"Count": "zu viele"})
|
||||
assert.Equal(t, "Ich habe zu viele Katzen.", msg)
|
||||
|
||||
// unknown language string
|
||||
tag := srv.ParseLang("xx")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.TranslatePlural("cats", 8, tag)
|
||||
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
|
||||
|
||||
// invalid specialized language string
|
||||
tag = srv.ParseLang("de-xx")
|
||||
msg = srv.TranslatePlural("cats", 8, tag)
|
||||
assert.Equal(t, "Ich habe 8 Katzen.", msg) // fall back to German
|
||||
|
||||
// invalid language string
|
||||
tag = srv.ParseLang("§$%/%&/(/&%/)(")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.TranslatePlural("cats", 8, tag)
|
||||
assert.Equal(t, "I've 8 cats.", msg) // fall back to English
|
||||
|
||||
// unknown
|
||||
msg = srv.TranslatePlural("this.key.will.never.exist", nil, language.English)
|
||||
assert.Equal(t, "this.key.will.never.exist (count=<nil>)", msg)
|
||||
|
||||
msg, err = srv.TranslatePluralMaybe("this.key.will.never.exist", nil, language.English)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, msg) // empty string for errors!
|
||||
}
|
||||
|
||||
func TestI18nUndetermined(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-undetermined"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
unwrappedErr := errors.Unwrap(err)
|
||||
require.Error(t, unwrappedErr)
|
||||
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 1 in i18n message bundle: [en und]"))
|
||||
}
|
||||
|
||||
func TestI18nUndeterminedDefaultLanguage(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.Und, // Undetermined is disallowed
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
unwrappedErr := errors.Unwrap(err)
|
||||
require.Error(t, unwrappedErr)
|
||||
assert.Equal(t, unwrappedErr, errors.New("undetermined language at index 0 in i18n message bundle: [und de en]"))
|
||||
}
|
||||
|
||||
func TestI18nInvalidToml(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n-invalid"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestI18nInexistantFolder(t *testing.T) {
|
||||
_, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.AmericanEnglish,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n/this/folder/does/not/exist"),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseAcceptLanguage(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
tag := srv.ParseAcceptLanguage("de,en-US;q=0.7,en;q=0.3")
|
||||
assert.Equal(t, language.German, tag)
|
||||
|
||||
tag = srv.ParseAcceptLanguage("de-AT,en-US;q=0.7,en;q=0.3")
|
||||
assert.NotEqual(t, language.German, tag) // actual: de-u-rg-atzzzz
|
||||
|
||||
// unknown language header
|
||||
tag = srv.ParseAcceptLanguage("xx,en-US;q=0.7,en;q=0.3")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
// invalid specialized language string
|
||||
tag = srv.ParseAcceptLanguage("de-xx,en-US;q=0.7,en;q=0.3")
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Guten Tag Hans", msg)
|
||||
|
||||
// invalid language header
|
||||
tag = srv.ParseAcceptLanguage("§$%/%&/(/&%/)(")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
}
|
||||
|
||||
func TestParseLanguage(t *testing.T) {
|
||||
srv, err := i18n.New(config.I18n{
|
||||
DefaultLanguage: language.English,
|
||||
BundleDirAbs: filepath.Join(util.GetProjectRootDir(), "/internal/i18n/testdata/i18n"),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
tag := srv.ParseLang("de")
|
||||
assert.Equal(t, language.German, tag)
|
||||
|
||||
// unknown language string
|
||||
tag = srv.ParseLang("xx")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg := srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
|
||||
// invalid specialized language string
|
||||
tag = srv.ParseLang("de-xx")
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Guten Tag Hans", msg) // fall back to German
|
||||
|
||||
// invalid language string
|
||||
tag = srv.ParseLang("§$%/%&/(/&%/)(")
|
||||
assert.Equal(t, language.English, tag)
|
||||
msg = srv.Translate("Test.Welcome", tag, i18n.Data{"Name": "Hans"})
|
||||
assert.Equal(t, "Welcome Hans", msg)
|
||||
}
|
||||
3
internal/i18n/testdata/README.md
vendored
Normal file
3
internal/i18n/testdata/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# `internal/i18n/testdata`
|
||||
|
||||
These translation toml files are not meant to be touched while doing application development, rather they are here to test the baseline functionality of the i18n package.
|
||||
0
internal/i18n/testdata/i18n-empty/.gitkeep
vendored
Normal file
0
internal/i18n/testdata/i18n-empty/.gitkeep
vendored
Normal file
2
internal/i18n/testdata/i18n-invalid/en.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-invalid/en.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# This file in invalid on purpose.
|
||||
This is not toml.
|
||||
13
internal/i18n/testdata/i18n-plural/de.toml
vendored
Normal file
13
internal/i18n/testdata/i18n-plural/de.toml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# {{.Count}} is injected by default while using service.TranslatePlural
|
||||
|
||||
# pluralized structured format
|
||||
[cats]
|
||||
zero="Ich habe keine Katze."
|
||||
one="Ich habe eine Katze."
|
||||
other="Ich habe {{.Count}} Katzen."
|
||||
|
||||
# pluralized single line format
|
||||
"dogs.zero"="Ich habe keinen Hund."
|
||||
"dogs.one"="Ich habe einen Hund."
|
||||
"dogs.other"="Ich habe {{.Count}} Hunde."
|
||||
|
||||
12
internal/i18n/testdata/i18n-plural/en.toml
vendored
Normal file
12
internal/i18n/testdata/i18n-plural/en.toml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# {{.Count}} is injected by default while using service.TranslatePlural
|
||||
|
||||
# pluralized structured format
|
||||
[cats]
|
||||
zero="I don't have a cat."
|
||||
one="I've one cat."
|
||||
other="I've {{.Count}} cats."
|
||||
|
||||
# pluralized single line format
|
||||
"dogs.zero"="I don't have a dog."
|
||||
"dogs.one"="I've one dog."
|
||||
"dogs.other"="I've {{.Count}} dogs."
|
||||
34
internal/i18n/testdata/i18n-reserved/de.toml
vendored
Normal file
34
internal/i18n/testdata/i18n-reserved/de.toml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# "reserved" keys:
|
||||
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
|
||||
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
|
||||
|
||||
"reserved1.zero"="Null"
|
||||
"reserved2.one"="Eins"
|
||||
"reserved3.two"="Zwei"
|
||||
"reserved4.few"="Wenig"
|
||||
"reserved5.many"="Mehr"
|
||||
"reserved6.other"="Andere"
|
||||
"reserved7.id"="ID"
|
||||
"reserved8.description"="Beschreibung"
|
||||
|
||||
[reservedMap]
|
||||
zero="Null"
|
||||
one="Eins"
|
||||
two="Zwei"
|
||||
few="Wenig"
|
||||
many="Mehr"
|
||||
other="Andere"
|
||||
|
||||
"reserved.plain.zero"="Null"
|
||||
"reserved.plain.one"="Eins"
|
||||
"reserved.plain.two"="Zwei"
|
||||
"reserved.plain.few"="Wenig"
|
||||
"reserved.plain.many"="Mehr"
|
||||
"reserved.plain.other"="Andere"
|
||||
|
||||
"reserved.plain2.id"="ID"
|
||||
"reserved.plain2.description"="Beschreibung"
|
||||
|
||||
"reserved.plain3.id"="ID"
|
||||
"reserved.plain3.description"="Beschreibung"
|
||||
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?
|
||||
34
internal/i18n/testdata/i18n-reserved/en.toml
vendored
Normal file
34
internal/i18n/testdata/i18n-reserved/en.toml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# "reserved" keys:
|
||||
# "id", "description", "hash", "leftdelim", "rightdelim", "zero", "one", "two", "few", "many", "other"
|
||||
# see https://github.com/nicksnyder/go-i18n/blob/2180cd9f35b3e125cfe3773a6bf3ea483347f060/v2/i18n/message.go#L181
|
||||
|
||||
"reserved1.zero"="Zero"
|
||||
"reserved2.one"="One"
|
||||
"reserved3.two"="Two"
|
||||
"reserved4.few"="Few"
|
||||
"reserved5.many"="Many"
|
||||
"reserved6.other"="Other"
|
||||
"reserved7.id"="id"
|
||||
"reserved8.description"="Description"
|
||||
|
||||
[reservedMap]
|
||||
zero="Zero"
|
||||
one="One"
|
||||
two="Two"
|
||||
few="Few"
|
||||
many="Many"
|
||||
other="Other"
|
||||
|
||||
"reserved.plain.zero"="Zero"
|
||||
"reserved.plain.one"="One"
|
||||
"reserved.plain.two"="Two"
|
||||
"reserved.plain.few"="Few"
|
||||
"reserved.plain.many"="Many"
|
||||
"reserved.plain.other"="Other"
|
||||
|
||||
"reserved.plain2.id"="id"
|
||||
"reserved.plain2.description"="Description"
|
||||
|
||||
"reserved.plain3.id"="id"
|
||||
"reserved.plain3.description"="Description"
|
||||
"reserved.plain3.test"="Test" # can i see this non-reserved one by FQDN?
|
||||
2
internal/i18n/testdata/i18n-specialized/de-at.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-specialized/de-at.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
punchline="Koan i Humor?"
|
||||
2
internal/i18n/testdata/i18n-specialized/de-de.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-specialized/de-de.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
punchline="Habe ich Humor?"
|
||||
2
internal/i18n/testdata/i18n-specialized/en-uk.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-specialized/en-uk.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
punchline="I can has HUMOUR?"
|
||||
2
internal/i18n/testdata/i18n-specialized/en-us.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-specialized/en-us.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
punchline="I can has HUMOR?"
|
||||
2
internal/i18n/testdata/i18n-undetermined/xx.toml
vendored
Normal file
2
internal/i18n/testdata/i18n-undetermined/xx.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
language="xx"
|
||||
4
internal/i18n/testdata/i18n/de.toml
vendored
Normal file
4
internal/i18n/testdata/i18n/de.toml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[Test]
|
||||
Body = "Das ist ein Test"
|
||||
Welcome = "Guten Tag {{.Name}}"
|
||||
"String.DE.only" = "This key only exists in DE"
|
||||
4
internal/i18n/testdata/i18n/en.toml
vendored
Normal file
4
internal/i18n/testdata/i18n/en.toml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
[Test]
|
||||
Body = "This is a test"
|
||||
Welcome = "Welcome {{.Name}}"
|
||||
"String.EN.only" = "This key only exists in EN"
|
||||
Reference in New Issue
Block a user