diff options
| author | Jeroen Wijenbergh <jeroen.wijenbergh@geant.org> | 2025-08-25 10:59:37 +0200 |
|---|---|---|
| committer | Jeroen Wijenbergh <jeroen.wijenbergh@geant.org> | 2025-08-25 13:06:41 +0200 |
| commit | 27b95b4911da055fe9b5fb37b5fb4a33eda6b989 (patch) | |
| tree | f6eb1143fa9bd2995d671b71d75c950e2c703660 /i18n | |
| parent | b4f4f5600298436c63b89f289c318d777300c499 (diff) | |
All: Remove util packages
Was giving linting errors and it's not a good idea anyways
Diffstat (limited to 'i18n')
| -rw-r--r-- | i18n/err/i18nerr.go | 168 | ||||
| -rw-r--r-- | i18n/i18n.go | 54 | ||||
| -rw-r--r-- | i18n/i18n_test.go | 47 |
3 files changed, 269 insertions, 0 deletions
diff --git a/i18n/err/i18nerr.go b/i18n/err/i18nerr.go new file mode 100644 index 0000000..8254dd4 --- /dev/null +++ b/i18n/err/i18nerr.go @@ -0,0 +1,168 @@ +// Package i18nerr implements errors with internationalization using gotext +package i18nerr + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + + "codeberg.org/eduVPN/eduvpn-common/internal/http" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var ( + printers sync.Map + once sync.Once +) + +// TranslatedInner defines errors that are used as inner causes but are still translated because they can happen frequently +func TranslatedInner(inner error) (string, bool) { + unwrapped := inner + for errors.Unwrap(unwrapped) != nil { + unwrapped = errors.Unwrap(unwrapped) + } + + var tErr *http.TimeoutError + switch { + case errors.As(inner, &tErr): + return printerOrNew(language.English).Sprintf("Timeout reached contacting URL: '%s'", tErr.URL), false + case errors.Is(inner, context.Canceled): + return unwrapped.Error(), true + } + return unwrapped.Error(), false +} + +// Error wraps an actual error with the translation key +// This translation key is later used to lookup translation +// The inner error always consists of the translation key and some formatting +type Error struct { + key message.Reference + args []any + wrapped *Error + Misc bool +} + +func (e *Error) translated(t language.Tag) string { + once.Do(func() { + inititializeLangs() + }) + msg := printerOrNew(t).Sprintf(e.key, e.args...) + if e.wrapped != nil { + return printerOrNew(t).Sprintf("%s. The cause of the error is: %s.", msg, e.wrapped.Error()) + } + return msg +} + +// Error gets the error string +// it does this by simply forwarding the error method from the actual inner error +func (e *Error) Error() string { + return e.translated(language.English) +} + +// Translations returns all the translations for the error including the source translation (english) +func (e *Error) Translations() map[string]string { + translations := make(map[string]string) + // add the source transltaion first + source := e.Error() + translations[language.English.String()] = source + for _, t := range message.DefaultCatalog.Languages() { + // already added + if t == language.English { + continue + } + // get the final translation string for the tag + // and add it if it's not equal to the english version + f := e.translated(t) + if f != source { + translations[t.String()] = f + } + } + return translations +} + +// Unwrap returns the unwrapped error +// it does this by unwrapping the inner error +func (e *Error) Unwrap() error { + if e.wrapped == nil { + return nil + } + return e.wrapped.Unwrap() +} + +// printerOrNew gets a message printer from the global printers map using the tag 'tag' +// If the printer cannot be found in the sync map, we return a new printer +func printerOrNew(tag language.Tag) *message.Printer { + v, ok := printers.Load(tag) + if !ok { + slog.Debug("i18n could not load printer from map", "tag", tag) + return message.NewPrinter(tag) + } + p, ok := v.(*message.Printer) + if !ok { + slog.Debug("i18n could not load printer from map with incorrect type", "tag", tag, "type", fmt.Sprintf("%T", p)) + return message.NewPrinter(tag) + } + return p +} + +// New creates a new i18n error using a message reference +func New(key message.Reference) *Error { + _ = printerOrNew(language.English).Sprint(key) + return &Error{key: key} +} + +// Newf creates a new i18n error using a message reference and arguments. +// It formats this with fmt.Errorf +func Newf(key message.Reference, args ...any) *Error { + _ = printerOrNew(language.English).Sprintf(key, args...) + return &Error{key: key, args: args} +} + +// Wrap creates a new i18n error using an error to be wrapped 'err' and a prefix message reference 'key'. +// It formats this with fmt.Errorf +func Wrap(err error, key message.Reference) *Error { + _ = printerOrNew(language.English).Sprintf(key) + t, misc := TranslatedInner(err) + return &Error{key: key, wrapped: &Error{key: t, Misc: misc}, Misc: misc} +} + +// Wrapf creates a new i18n error using an error to be wrapped 'err' and a prefix message reference 'key' with format arguments 'args'. +// It formats this with fmt.Errorf +func Wrapf(err error, key message.Reference, args ...any) *Error { + _ = printerOrNew(language.English).Sprintf(key, args...) + t, misc := TranslatedInner(err) + return &Error{key: key, args: args, wrapped: &Error{key: t, Misc: misc}, Misc: misc} +} + +// NewInternal creates an internal localised error from a display string +func NewInternal(disp string) *Error { + return Wrap(errors.New(disp), "An internal error occurred") +} + +// NewInternalf creates an internal localised error from a display string and arguments +func NewInternalf(disp string, args ...any) *Error { + return NewInternal(fmt.Sprintf(disp, args...)) +} + +// WrapInternal wraps an error and a display string into a localised internal error +func WrapInternal(err error, disp string) *Error { + return Wrap(fmt.Errorf("%s with internal cause: %w", disp, err), "An internal error occurred") +} + +// WrapInternalf wraps an error and a display string with args into a localised internal error +func WrapInternalf(err error, disp string, args ...any) *Error { + return WrapInternal(err, fmt.Sprintf(disp, args...)) +} + +// initializeLangs initializes the printers from the default catalog into the sync map +// we cannot do this in init() because this is too early +func inititializeLangs() { + slog.Debug("i18n initializing languages") + for _, t := range message.DefaultCatalog.Languages() { + printers.Store(t, message.NewPrinter(t)) + } +} diff --git a/i18n/i18n.go b/i18n/i18n.go new file mode 100644 index 0000000..02b9028 --- /dev/null +++ b/i18n/i18n.go @@ -0,0 +1,54 @@ +// Package i18n implements utility functions for internationalization +package i18n + +import "strings" + +// GetLanguageMatched uses a map from language tags to strings to extract the right language given the tag +// It implements it according to https://github.com/eduvpn/documentation/blob/dc4d53c47dd7a69e95d6650eec408e16eaa814a2/SERVER_DISCOVERY.md#language-matching +func GetLanguageMatched(langMap map[string]string, langTag string) string { + // If no map is given, return the empty string + if len(langMap) == 0 { + return "" + } + // Try to find the exact match + if val, ok := langMap[langTag]; ok { + return val + } + // Try to find a key that starts with the OS language setting + for k := range langMap { + if strings.HasPrefix(k, langTag) { + return langMap[k] + } + } + // Try to find a key that starts with the first part of the OS language (e.g. de-) + pts := strings.Split(langTag, "-") + // We have a "-" + if len(pts) > 1 { + for k := range langMap { + if strings.HasPrefix(k, pts[0]+"-") { + return langMap[k] + } + } + } + // search for just the language (e.g. de) + for k := range langMap { + if k == pts[0] { + return langMap[k] + } + } + + // Pick one that is deemed best, e.g. en-US or en, but note that not all languages are always available! + // We force an entry that is english exactly or with an english prefix + for k := range langMap { + if k == "en" || strings.HasPrefix(k, "en-") { + return langMap[k] + } + } + + // Otherwise just return one + for k := range langMap { + return langMap[k] + } + + return "" +} diff --git a/i18n/i18n_test.go b/i18n/i18n_test.go new file mode 100644 index 0000000..fc671a4 --- /dev/null +++ b/i18n/i18n_test.go @@ -0,0 +1,47 @@ +package i18n + +import "testing" + +func TestGetLanguageMatched(t *testing.T) { + // exact match + returned := GetLanguageMatched(map[string]string{"en": "test", "de": "test2"}, "en") + if returned != "test" { + t.Fatalf("Got: %s, want: %s", returned, "test") + } + + // starts with language tag + returned = GetLanguageMatched(map[string]string{"en-US-test": "test", "de": "test2"}, "en-US") + if returned != "test" { + t.Fatalf("Got: %s, want: %s", returned, "test") + } + + // starts with en- + returned = GetLanguageMatched(map[string]string{"en-UK": "test", "en": "test2"}, "en-US") + if returned != "test" { + t.Fatalf("Got: %s, want: %s", returned, "test") + } + + // exact match for en + returned = GetLanguageMatched(map[string]string{"de": "test", "en": "test2"}, "en-US") + if returned != "test2" { + t.Fatalf("Got: %s, want: %s", returned, "test2") + } + + // We default to english + returned = GetLanguageMatched(map[string]string{"es": "test", "en": "test2"}, "nl-NL") + if returned != "test2" { + t.Fatalf("Got: %s, want: %s", returned, "test2") + } + + // We default to english with a - as well + returned = GetLanguageMatched(map[string]string{"est": "test", "en-": "test2"}, "en-US") + if returned != "test2" { + t.Fatalf("Got: %s, want: %s", returned, "test2") + } + + // None found just return one + returned = GetLanguageMatched(map[string]string{"es": "test"}, "en-US") + if returned != "test" { + t.Fatalf("Got: %s, want: %s", returned, "test") + } +} |
