summaryrefslogtreecommitdiff
path: root/i18n
diff options
context:
space:
mode:
Diffstat (limited to 'i18n')
-rw-r--r--i18n/err/i18nerr.go168
-rw-r--r--i18n/i18n.go54
-rw-r--r--i18n/i18n_test.go47
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")
+ }
+}