diff options
Diffstat (limited to 'src/i18n/i18n.go')
-rw-r--r-- | src/i18n/i18n.go | 518 |
1 files changed, 518 insertions, 0 deletions
diff --git a/src/i18n/i18n.go b/src/i18n/i18n.go new file mode 100644 index 0000000..054eaef --- /dev/null +++ b/src/i18n/i18n.go @@ -0,0 +1,518 @@ +package i18n + +import ( + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/leonelquinteros/gotext" +) + +type Printer struct { + LocaleInfo + inner *gotext.Locale +} + +type LocaleInfo struct { + Bcp, Name string + Eurozone, Enabled bool + DateFormat string + ThousandsSeparator, DecimalSeparator rune + MonetaryPre, MonetaryPost [2]string +} + +type number interface { + int | float64 +} + +type sprintfFunc func(LocaleInfo, *strings.Builder, any) error + +var ( + handlers map[rune]sprintfFunc = map[rune]sprintfFunc{ + -1: sprintfGeneric, + 'e': sprintfe, + 'E': sprintfE, + 'l': sprintfl, + 'L': sprintfL, + 'm': sprintfm, + 'r': sprintfr, + } + + /* To determine the correct date format to use, use the ‘datefmt’ script in + the repository root */ + locales = [...]LocaleInfo{ + { + Bcp: "ca", + Name: "català", + DateFormat: "2/1/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "de", + Name: "Deutsch", + DateFormat: "2.1.2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "el", + Name: "ελληνικά", + DateFormat: "2/1/2006", + Eurozone: true, + Enabled: true, + }, + { + Bcp: "en", + Name: "English", + DateFormat: "02/01/2006", + Eurozone: true, + Enabled: true, + ThousandsSeparator: ',', + DecimalSeparator: '.', + MonetaryPre: [2]string{"€", "-€"}, + }, + { + Bcp: "es", + Name: "español", + DateFormat: "2/1/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "et", + Name: "eesti", + DateFormat: "2.1.2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "fi", + Name: "suomi", + DateFormat: "2.1.2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "fr", + Name: "français", + DateFormat: "02/01/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "ga", + Name: "Gaeilge", + DateFormat: "02/01/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "hr", + Name: "hrvatski", + DateFormat: "02. 01. 2006.", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "it", + Name: "italiano", + DateFormat: "02/01/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "lb", + Name: "lëtzebuergesch", + DateFormat: "2.1.2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "lt", + Name: "lietuvių", + DateFormat: "2006-01-02", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "lv", + Name: "latviešu", + DateFormat: "2.01.2006.", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "mt", + Name: "Malti", + DateFormat: "2/1/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "nl", + Name: "Nederlands", + DateFormat: "2-1-2006", + Eurozone: true, + Enabled: true, + ThousandsSeparator: '.', + DecimalSeparator: ',', + MonetaryPre: [2]string{"€ ", "€ -"}, + }, + { + Bcp: "pt", + Name: "português", + DateFormat: "02/01/2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "sk", + Name: "slovenčina", + DateFormat: "2. 1. 2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "sl", + Name: "slovenščina", + DateFormat: "2. 1. 2006", + Eurozone: true, + Enabled: false, + }, + { + Bcp: "sv", + Name: "svenska", + DateFormat: "2006-01-02", + Eurozone: true, + Enabled: false, + }, + + /* Non-Eurozone locales */ + { + Bcp: "bg", + Name: "български", + DateFormat: "2.01.2006 г.", + Eurozone: false, /* TODO(2026): Set to true */ + Enabled: true, + }, + { + Bcp: "da", + Name: "dansk", + DateFormat: "02.01.2006", + Eurozone: false, + Enabled: false, + }, + { + Bcp: "en-US", + Name: "English (US)", + DateFormat: "1/2/2006", + Eurozone: false, + Enabled: false, + }, + { + Bcp: "hu", + Name: "magyar", + DateFormat: "2006. 01. 02.", + Eurozone: false, + Enabled: false, + }, + { + Bcp: "pl", + Name: "polski", + DateFormat: "2.01.2006", + Eurozone: false, + Enabled: false, + }, + { + Bcp: "ro", + Name: "română", + DateFormat: "02.01.2006", + Eurozone: false, + Enabled: false, + }, + { + Bcp: "uk", + Name: "yкраїнська", + DateFormat: "02.01.2006", + Eurozone: false, + Enabled: false, + }, + } + /* Map of language codes to printers. We do this instead of just + using language.MustParse() directly so that we can easily see if a + language is supported or not. */ + Printers map[string]Printer = make(map[string]Printer, len(locales)) + DefaultPrinter Printer +) + +func Init() { + for _, li := range locales { + if !li.Enabled { + continue + } + Printers[li.Bcp] = Printer{li, gotext.NewLocale("po", li.Bcp)} + } + + DefaultPrinter = Printers["en"] +} + +func Locales() []LocaleInfo { + return locales[:] +} + +func (p Printer) Get(fmt string, args ...map[string]any) string { + /* TODO: Warning if you pass more than 1 arg? */ + var m map[string]any + if len(args) == 0 { + m = make(map[string]any) + } else { + m = args[0] + } + + return p.Sprintf(p.inner.Get(fmt), m) +} + +func (p Printer) GetN(fmtS, fmtP string, n int, args map[string]any) string { + return p.Sprintf(p.inner.GetN(fmtS, fmtP, n), args) +} + +/* Transform ‘en-US’ to ‘en’ */ +func (l LocaleInfo) Language() string { + return l.Bcp[:2] +} + +func (p Printer) Sprintf(format string, args map[string]any) string { + var bob strings.Builder + args["-"] = "" + + for { + i := strings.IndexByte(format, '%') + if i == -1 { + bob.WriteString(format) + break + } + bob.WriteString(format[:i]) + + format = format[i+1:] + if len(format) == 0 { + /* TODO: Handle error: trailing percent */ + break + } + + b := format[0] + format = format[1:] + + switch b { + case '%': + bob.WriteByte(b) + case '(': + i = strings.IndexRune(format, ')') + if i == -1 { + /* TODO: Handle error: unterminated %( */ + return "unterminated %(" + } + + parts := strings.Split(format[:i], ":") + format = format[i+1:] + + var flag rune + switch len(parts) { + case 1: + flag = -1 + case 2: + f, n := utf8.DecodeRune([]byte(parts[1])) + if n != len(parts[1]) { + /* TODO: Handle error: flag too long or empty */ + return "flag too long or empty" + } + flag = f + default: + /* TODO: Handle error: too many colons */ + return "too many colons" + } + + h, ok := handlers[flag] + if !ok { + /* TODO: Handle error: no such handler */ + return "no such handler" + } + + v, ok := args[parts[0]] + if !ok { + /* TODO: Handle error: no such key */ + return "no such key" + } + h(p.LocaleInfo, &bob, v) + default: + /* TODO: Handle error: invalid escape */ + bob.WriteByte(b) + } + } + + return bob.String() +} + +func sprintfGeneric(li LocaleInfo, bob *strings.Builder, v any) error { + switch v.(type) { + case time.Time: + bob.WriteString(v.(time.Time).Format(li.DateFormat)) + case int: + writeInt(bob, v.(int), li.ThousandsSeparator) + case float64: + writeFloat(bob, v.(float64), li.ThousandsSeparator, li.DecimalSeparator) + case string: + bob.WriteString(v.(string)) + default: + bob.WriteString(fmt.Sprint(v)) + } + return nil +} + +func sprintfe(li LocaleInfo, bob *strings.Builder, v any) error { + s, ok := v.(string) + if !ok { + return errors.New("TODO") + } + bob.WriteString("\u2068<a href=\"mailto:") + attrEscape(bob, s) + bob.WriteString("\">\u2069") + bob.WriteString(s) + bob.WriteString("\u2068</a>\u2069") + return nil +} + +func sprintfE(li LocaleInfo, bob *strings.Builder, _ any) error { + bob.WriteString("\u2068</a>\u2069") + return nil +} + +func sprintfl(li LocaleInfo, bob *strings.Builder, v any) error { + s, ok := v.(string) + if !ok { + return errors.New("TODO") + } + bob.WriteString("\u2068<a href=\"") + attrEscape(bob, s) + bob.WriteString("\">\u2069") + return nil +} + +func sprintfL(li LocaleInfo, bob *strings.Builder, v any) error { + s, ok := v.(string) + if !ok { + return errors.New("TODO") + } + bob.WriteString("\u2068<a href=\"") + attrEscape(bob, s) + bob.WriteString("\" target=\"_blank\">\u2069") + return nil +} + +func sprintfm(li LocaleInfo, bob *strings.Builder, v any) error { + switch v.(type) { + case int: + n := v.(int) + i := btoi(n >= 0) + bob.WriteString(li.MonetaryPre[i]) + writeInt(bob, abs(n), li.ThousandsSeparator) + bob.WriteString(li.MonetaryPost[i]) + case float64: + n := v.(float64) + i := btoi(n >= 0) + bob.WriteString(li.MonetaryPre[i]) + writeFloat(bob, abs(n), li.ThousandsSeparator, li.DecimalSeparator) + bob.WriteString(li.MonetaryPost[i]) + default: + return errors.New("TODO") + } + return nil +} + +func sprintfr(li LocaleInfo, bob *strings.Builder, v any) error { + s, ok := v.(string) + if !ok { + return errors.New("TODO") + } + bob.WriteRune('\u2068') + bob.WriteString(s) + bob.WriteRune('\u2069') + return nil +} + +func attrEscape(bob *strings.Builder, s string) { + for _, r := range s { + switch r { + case '<': + bob.WriteString("<") + case '&': + bob.WriteString("&") + case '"': + bob.WriteString(""") + default: + bob.WriteRune(r) + } + } +} + +func writeInt(bob *strings.Builder, num int, sep rune) { + s := fmt.Sprintf("%d", num) + if s[0] == '-' { + bob.WriteByte('-') + s = s[1:] + } + n := len(s) + c := 3 - n%3 + if c == 3 { + c = 0 + } + for i := 0; i < n; i++ { + c++ + bob.WriteByte(s[i]) + if c == 3 && i+1 < n { + bob.WriteRune(sep) + c = 0 + } + } +} + +func writeFloat(bob *strings.Builder, num float64, tsep, dsep rune) { + s := fmt.Sprintf("%.2f", num) + if s[0] == '-' { + bob.WriteByte('-') + s = s[1:] + } + + n := strings.IndexByte(s, '.') + c := 3 - n%3 + if c == 3 { + c = 0 + } + for i := 0; i < n; i++ { + c++ + bob.WriteByte(s[i]) + if c == 3 && i+1 < n { + bob.WriteRune(tsep) + c = 0 + } + } + + bob.WriteRune(dsep) + bob.WriteString(s[n+1:]) +} + +func abs[T number](x T) T { + if x < 0 { + return -x + } + return x +} + +func btoi(b bool) int { + if b { + return 0 + } + return 1 +} |