diff --git a/locale/da/locale.go b/locale/da/locale.go index 0e272de..5bf7216 100644 --- a/locale/da/locale.go +++ b/locale/da/locale.go @@ -9,40 +9,40 @@ var Code = "da" var Locale = locale.Map{ "slice": locale.Map{ "connector": "og", - "rest": "èn anden|%d andre", + "rest": "[1] èn anden|[2-*] %d andre", }, "time": locale.Map{ "now": "lige nu", "future": "om %s", "past": "%s siden", "estimation": locale.Map{ - "s": "et sekund|%d sekunder", - "m": "et minut|%d minutter", - "h": "en time|%d timer", - "d": "en dag|%d dage", - "w": "en uge|%d uger", - "M": "en måned|%d måneder", - "y": "et år|%d år", - "D": "et årti|%d årtier", + "s": "[0-5] et par sekunder|[6-*] %d sekunder", + "m": "[1] et minut|[2-*] %d minutter", + "h": "[1] en time|[2-*] %d timer", + "d": "[1] en dag|[2-*] %d dage", + "w": "[1] en uge|[2-*] %d uger", + "M": "[1] en måned|[2-*] %d måneder", + "y": "[1] et år|[2-*] %d år", + "D": "[1] et årti|[2-*] %d årtier", "l": "lang tid", }, "precision": locale.Map{ - "s": "1 sekund|%d sekunder", - "m": "1 minut|%d minutter", - "h": "1 time|%d timer", - "d": "1 dag|%d dage", - "M": "1 måned|%d måneder", - "y": "1 år|%d år", + "s": "[1] 1 sekund|[2-*] %d sekunder", + "m": "[1] 1 minut|[2-*] %d minutter", + "h": "[1] 1 time|[2-*] %d timer", + "d": "[1] 1 dag|[2-*] %d dage", + "M": "[1] 1 måned|[2-*] %d måneder", + "y": "[1] 1 år|[2-*] %d år", }, }, "int": locale.Map{ - "K": "tusind|tusinde", - "M": "million|millioner", - "B": "milliard|milliarder", - "T": "billion|billioner", - "Q": "billiard|billiarder", - "Qi": "trillion|trillioner", - "Sx": "trilliard|trilliarder", - "Sp": "kvadrillion|kvadrillioner", + "K": "[1] tusind|[2-*] tusinde", + "M": "[1] million|[2-*] millioner", + "B": "[1] milliard|[2-*] milliarder", + "T": "[1] billion|[2-*] billioner", + "Q": "[1] billiard|[2-*] billiarder", + "Qi": "[1] trillion|[2-*] trillioner", + "Sx": "[1] trilliard|[2-*] trilliarder", + "Sp": "[1] kvadrillion|[2-*] kvadrillioner", }, } diff --git a/locale/de/locale.go b/locale/de/locale.go index 9c32612..eccc21b 100644 --- a/locale/de/locale.go +++ b/locale/de/locale.go @@ -9,40 +9,40 @@ var Code = "de" var Locale = locale.Map{ "slice": locale.Map{ "connector": "und", - "rest": "ein anderer|%d andere", + "rest": "[1] ein anderer|[2-*] %d andere", }, "time": locale.Map{ "now": "gerade jetzt", "future": "in %s", "past": "vor %s", "estimation": locale.Map{ - "s": "eine Sekunde|%d Sekunden", - "m": "eine Minute|%d Minuten", - "h": "eine Stunde|%d Stunden", - "d": "ein Tag|%d Tage", - "w": "eine Woche|%d Wochen", - "M": "ein Monat|%d Monate", - "y": "ein Jahr|%d Jahre", - "D": "ein Jahrzehnt|%d Jahrzehnte", + "s": "[1] eine Sekunde|[2-*] %d Sekunden", + "m": "[1] eine Minute|[2-*] %d Minuten", + "h": "[1] eine Stunde|[2-*] %d Stunden", + "d": "[1] ein Tag|[2-*] %d Tage", + "w": "[1] eine Woche|[2-*] %d Wochen", + "M": "[1] ein Monat|[2-*] %d Monate", + "y": "[1] ein Jahr|[2-*] %d Jahre", + "D": "[1] ein Jahrzehnt|[2-*] %d Jahrzehnte", "l": "lange Zeit", }, "precision": locale.Map{ - "s": "1 Sekunde|%d Sekunden", - "m": "1 Minute|%d Minuten", - "h": "1 Stunde|%d Stunden", - "d": "1 Tag|%d Tage", - "M": "1 Monat|%d Monate", - "y": "1 Jahr|%d Jahre", + "s": "[1] 1 Sekunde|[2-*] %d Sekunden", + "m": "[1] 1 Minute|[2-*] %d Minuten", + "h": "[1] 1 Stunde|[2-*] %d Stunden", + "d": "[1] 1 Tag|[2-*] %d Tage", + "M": "[1] 1 Monat|[2-*] %d Monate", + "y": "[1] 1 Jahr|[2-*] %d Jahre", }, }, "int": locale.Map{ "K": "tausend", - "M": "Million|Millionen", - "B": "Milliarde|Milliarden", - "T": "Billion|Billionen", - "Q": "Billiarde|Billiarden", - "Qi": "Trillion|Trillionen", - "Sx": "Sextillion|Sextillionen", - "Sp": "Septillion|Septillionen", + "M": "[1] Million|[2-*] Millionen", + "B": "[1] Milliarde|[2-*] Milliarden", + "T": "[1] Billion|[2-*] Billionen", + "Q": "[1] Billiarde|[2-*] Billiarden", + "Qi": "[1] Trillion|[2-*] Trillionen", + "Sx": "[1] Sextillion|[2-*] Sextillionen", + "Sp": "[1] Septillion|[2-*] Septillionen", }, } diff --git a/locale/en/locale.go b/locale/en/locale.go index c1364b5..57c2177 100644 --- a/locale/en/locale.go +++ b/locale/en/locale.go @@ -11,30 +11,30 @@ var Code = "en" var Locale = locale.Map{ "slice": locale.Map{ "connector": "and", - "rest": "one other|%d others", + "rest": "[1] one other|[2-*] %d others", }, "time": locale.Map{ "now": "just now", "future": "in %s", "past": "%s ago", "estimation": locale.Map{ - "s": "a second|%d seconds", - "m": "a minute|%d minutes", - "h": "an hour|%d hours", - "d": "a day|%d days", - "w": "a week|%d weeks", - "M": "a month|%d months", - "y": "a year|%d years", - "D": "a decade|%d decades", + "s": "[0-5] a few seconds|[6-*] %d seconds", + "m": "[1] a minute|[2-*] %d minutes", + "h": "[1] an hour|[2-*] %d hours", + "d": "[1] a day|[2-*] %d days", + "w": "[1] a week|[2-*] %d weeks", + "M": "[1] a month|[2-*] %d months", + "y": "[1] a year|[2-*] %d years", + "D": "[1] a decade|[2-*] %d decades", "l": "a long time", }, "precision": locale.Map{ - "s": "1 second|%d seconds", - "m": "1 minute|%d minutes", - "h": "1 hour|%d hours", - "d": "1 day|%d days", - "M": "1 month|%d months", - "y": "1 year|%d years", + "s": "[1] 1 second|[2-*] %d seconds", + "m": "[1] 1 minute|[2-*] %d minutes", + "h": "[1] 1 hour|[2-*] %d hours", + "d": "[1] 1 day|[2-*] %d days", + "M": "[1] 1 month|[2-*] %d months", + "y": "[1] 1 year|[2-*] %d years", }, }, "int": locale.Map{ diff --git a/locale/manager.go b/locale/manager.go index d10f488..04fff40 100644 --- a/locale/manager.go +++ b/locale/manager.go @@ -2,7 +2,6 @@ package locale import ( "fmt" - "strings" ) // Manager ... @@ -41,6 +40,23 @@ func (m *Manager) getTranslationOrFallback(path string) string { return translation } +func (m *Manager) getPluralizationOrFallback(path string, count int) string { + pluralizer, err := m.currentTranslator.getPluralizer(path) + + if err != nil { + // at this point, we will try to use the fallback translator + // and as a last option, falling back to the default fallback string + // if everything else fails + pluralizer, err = m.fallbackTranslator.getPluralizer(path) + + if err != nil { + return m.getTranslationOrFallback(path) + } + } + + return pluralizer.apply(count) +} + // Translate ... func (m *Manager) Translate(path string, args ...interface{}) string { translation := m.getTranslationOrFallback(path) @@ -54,22 +70,7 @@ func (m *Manager) Translate(path string, args ...interface{}) string { // Pluralize ... func (m *Manager) Pluralize(path string, count int) string { - translation := m.getTranslationOrFallback(path) - parts := strings.Split(translation, pluralSeparator) - - if count != 1 && len(parts) > 1 { - return m.applyCountToTranslation(parts[1], count) - } - - return parts[0] -} - -func (m *Manager) applyCountToTranslation(translation string, count int) string { - if strings.Contains(translation, "%d") { - return fmt.Sprintf(translation, count) - } - - return translation + return m.getPluralizationOrFallback(path, count) } func (m *Manager) addTranslator(code string, translations Map) *translator { diff --git a/locale/manager_test.go b/locale/manager_test.go index 2f26bf1..840ac6e 100644 --- a/locale/manager_test.go +++ b/locale/manager_test.go @@ -41,9 +41,9 @@ func TestManagerTranslate(t *testing.T) { func TestManagerPluralize(t *testing.T) { manager := NewManager(WithLocale("default", Map{ - "dollar": "I only have 1 dollar|I have %d dollars!!!!", + "dollar": "[1] I only have 1 dollar|[2-*] I have %d dollars!!!!", "time": Map{ - "minutes": "one minute|%d minutes", + "minutes": "[1] one minute|[2-*] %d minutes", }, "normal": "Everything is normal", })) @@ -58,6 +58,7 @@ func TestManagerPluralize(t *testing.T) { {"time.minutes", "2 minutes", 2}, {"normal", "Everything is normal", 1}, {"normal", "Everything is normal", 100}, + {"none.existing.path", "", 1}, } for _, test := range tests { @@ -90,21 +91,22 @@ func TestManagerSetLocale(t *testing.T) { } } -func TestManagerApplyCountToTranslation(t *testing.T) { +func TestManagerRegisterLocale(t *testing.T) { manager := NewManager() - tests := []struct { - text string - count int - expected string - }{ - {"%d bananas", 55, "55 bananas"}, - {"1 banana", 55, "1 banana"}, + if err := manager.RegisterLocale("1", Map{}); err != nil { + t.Errorf("expected no error, but got: %s", err) } - for _, test := range tests { - if actual := manager.applyCountToTranslation(test.text, test.count); actual != test.expected { - t.Errorf("expected `%s`, but got `%s`", test.expected, actual) - } + if err := manager.RegisterLocale("1", Map{}); err == nil { + t.Error("expected error, but got none") + } +} + +func TestManagerSetFallbackLocale(t *testing.T) { + manager := NewManager() + + if err := manager.SetFallbackLocale("1"); err == nil { + t.Error("expected error, but got none") } } diff --git a/locale/pluralizer.go b/locale/pluralizer.go new file mode 100644 index 0000000..add212f --- /dev/null +++ b/locale/pluralizer.go @@ -0,0 +1,161 @@ +package locale + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + boundaryRegex = "^\\[(\\d+)-?(\\d+|\\*)?\\](.*$)" + pluralSeparator = "|" +) + +type boundary struct { + min, max int + text string +} + +type pluralizer struct { + source string + boundaries []*boundary +} + +func (p *pluralizer) getText(count int) string { + for _, b := range p.boundaries { + // if min is equal to max and count exactly matches min + // return text, as this would be a singular boundary e.g. [1] some text + if count == b.min && b.max == b.min { + return b.text + } + + // if count is greater than min and less than max or + // max is equal to * (-1, infinity), return text + if count >= b.min && (b.max == -1 || count <= b.max) { + return b.text + } + } + + // if no boundaries match, return the last boundary + return p.boundaries[len(p.boundaries)-1].text +} + +func (p *pluralizer) apply(count int) string { + return p.applyCountToText(p.getText(count), count) +} + +func (p *pluralizer) applyCountToText(text string, count int) string { + if strings.Contains(text, "%d") { + return fmt.Sprintf(text, count) + } + + return text +} + +func createPluralizer(source string) (*pluralizer, error) { + boundaries, err := getBoundariesFromSource(source) + + if err != nil { + return nil, err + } + + return &pluralizer{source, boundaries}, nil +} + +func createBoundaryFromSubmatch(group []string) (*boundary, error) { + // captured groups length must be at least 2 and maximum 4, + // to contain full-capture, min, max? and text + if len(group) != 4 { + return nil, fmt.Errorf("invalid boundary format: %s", group) + } + + minStringValue := group[1] + maxStringValue := group[2] + text := strings.TrimSpace(group[3]) + + // convert minimum bound value to int + // e.g. [1] some text + // + // we could void this error check as + // we already know that the regex matched + min, _ := strconv.Atoi(minStringValue) + bound := &boundary{min: min, max: -1, text: text} + + // if both groups are captured + // e.g. [1-3] some text + // or [1-*] some text + if maxStringValue != "" { + if maxStringValue == "*" { + bound.max = -1 + return bound, nil + } + + // we can void this error check as + // we already know that the regex matched + max, _ := strconv.Atoi(maxStringValue) + + bound.max = max + + if max < min { + return nil, fmt.Errorf("invalid boundary format, max must be higher than min: %s", group) + } + + return bound, nil + } + + // if max is not set + // max will be equal to min + bound.max = bound.min + + return bound, nil +} + +func getBoundariesFromSource(source string) ([]*boundary, error) { + var boundaries []*boundary + parts := strings.Split(source, pluralSeparator) + if len(parts) == 1 { + return nil, fmt.Errorf("invalid plural source: %s", source) + } + + // keeping track of last boundary to + // make sure we don't have overlapping boundaries + lastBoundary := &boundary{min: -1, max: -1} + + r := regexp.MustCompile(boundaryRegex) + + for _, part := range parts { + groups := r.FindStringSubmatch(part) + bound, err := createBoundaryFromSubmatch(groups) + if err != nil { + return nil, err + } + + // if the new boundary is overlapping with the last boundary + // we need to merge them + // unless the current boundary max is equal to * (-1, infinity) + if bound.min <= lastBoundary.max && bound.max != -1 { + bound.min = lastBoundary.max + 1 + + // if min is higher than max, set max to min + if bound.min >= bound.max { + bound.max = bound.min + } + + } + + boundaries = append(boundaries, bound) + + // set the last boundary to the current boundary + lastBoundary = bound + + // if bound max is infinity, + // there should not be any other boundaries + // as they would be redundant + if bound.max == -1 { + break + } + } + + return boundaries, nil +} diff --git a/locale/pluralizer_test.go b/locale/pluralizer_test.go new file mode 100644 index 0000000..cf16c0c --- /dev/null +++ b/locale/pluralizer_test.go @@ -0,0 +1,167 @@ +package locale + +import ( + "testing" +) + +func TestPluralizer(t *testing.T) { + tests := []struct { + text string + boundaries []*boundary + shouldError bool + }{ + { + "[1] Some text|[2] some other text", + []*boundary{ + {1, 1, "Some text"}, + {2, 2, "some other text"}, + }, + false, + }, + { + "[1] Text 1|[2-*] Text 2", + []*boundary{ + {1, 1, "Text 1"}, + {2, -1, "Text 2"}, + }, + false, + }, + { + "[1] Text 1", + []*boundary{}, + true, + }, + { + "[0] Text 0|[1-*] Test 2", + []*boundary{ + {0, 0, "Text 0"}, + {1, -1, "Test 2"}, + }, + false, + }, + { + "[1-*] Text 1|[2-*] Text 2", + []*boundary{ + {1, -1, "Text 1"}, + }, + false, + }, + { + "text with no boundaries", + []*boundary{}, + true, + }, + { + "[*-*] Text 1|[2-*] Text 2", + []*boundary{}, + true, + }, + { + "[1-@] Text 1|[2-*] Text 2", + []*boundary{}, + true, + }, + { + "[-100, 5] Text 1|[2] Text 2", + []*boundary{}, + true, + }, + { + "[5-1] Text 1|[1] Text 2", + []*boundary{}, + true, + }, + { + "[2] Text 1|[1] Text 2", + []*boundary{ + {2, 2, "Text 1"}, + {3, 3, "Text 2"}, + }, + false, + }, + { + "[2-100] Text 1|[5-200] Text 2", + []*boundary{ + {2, 100, "Text 1"}, + {101, 200, "Text 2"}, + }, + false, + }, + { + "[2-100] Text 1|[5-95] Text 2", + []*boundary{ + {2, 100, "Text 1"}, + {101, 101, "Text 2"}, + }, + false, + }, + } + + for _, test := range tests { + instance, err := createPluralizer(test.text) + + if err == nil && test.shouldError { + t.Error("Expected error, but got none") + continue + } + + if err != nil { + if !test.shouldError { + t.Errorf("Expected no error, got %s", err) + continue + } + + continue + } + + if len(test.boundaries) != len(instance.boundaries) { + t.Errorf("Expected %d boundaries, got %d", len(test.boundaries), len(instance.boundaries)) + continue + } + + for i, boundary := range instance.boundaries { + checkBoundaryEquality(t, test.boundaries[i], boundary) + } + } +} + +func TestPluralizerApply(t *testing.T) { + tests := []struct { + actual, expected string + count int + }{ + {"[1] Text %d|[2] No", "Text 1", 1}, + {"[1] Test 1|[2-*] %d bananas", "8 bananas", 8}, + {"[1-5] A few seconds|[6-100] A lot of seconds|[101-500] %d seconds", "A few seconds", 3}, + {"[1-5] A few seconds|[6-100] A lot of seconds|[101-500] %d seconds", "A lot of seconds", 88}, + {"[1-5] A few seconds|[6-100] A lot of seconds|[101-500] %d seconds", "404 seconds", 404}, + {"[1-5] A few seconds|[6-100] A lot of seconds|[101-500] %d seconds", "904 seconds", 904}, + } + + for _, test := range tests { + instance, err := createPluralizer(test.actual) + + if err != nil { + t.Fatal(err) + } + + result := instance.apply(test.count) + if result != test.expected { + t.Errorf("Expected %s, got %s", test.expected, result) + } + } +} + +func checkBoundaryEquality(t *testing.T, a, b *boundary) { + if a.min != b.min { + t.Errorf("expected to get min %d, got %d", a.min, b.min) + } + + if a.max != b.max { + t.Errorf("expected to get max %d, got %d", a.max, b.max) + } + + if a.text != b.text { + t.Errorf("expected to get text %s, got %s", a.text, b.text) + } +} diff --git a/locale/translator.go b/locale/translator.go index 76a03aa..74eb621 100644 --- a/locale/translator.go +++ b/locale/translator.go @@ -6,31 +6,67 @@ import ( ) const ( - pluralSeparator = "|" - pathSeparator = "." + pathSeparator = "." ) // Translator ... type translator struct { - code string - translations Map + code string + translations Map + pluralizationCache map[string]*pluralizer + translationCache map[string]string } // NewTranslator ... func newTranslator(code string, translations Map) *translator { return &translator{ - code: code, - translations: translations, + code: code, + translations: translations, + pluralizationCache: map[string]*pluralizer{}, + translationCache: map[string]string{}, } } +func (t *translator) getPluralizer(path string) (*pluralizer, error) { + // check if a pluralizer has been cached + // and return it if it has + if pluralizer, ok := t.pluralizationCache[path]; ok { + return pluralizer, nil + } + + translation, err := t.getTranslation(path) + if err != nil { + return nil, err + } + + pluralizer, err := createPluralizer(translation) + + if err != nil { + return nil, err + } + + // save pluralizer to cache + t.pluralizationCache[path] = pluralizer + + return pluralizer, nil +} + func (t *translator) getTranslation(path string) (string, error) { + // check if a translation has been cached + // and return it if it has + if translation, ok := t.translationCache[path]; ok { + return translation, nil + } + translation := t.get(path) if translation == nil { return "", fmt.Errorf("could not find translation with path `%s`", path) } if stringValue, ok := translation.(string); ok { + // save translation to cache + t.translationCache[path] = stringValue + return stringValue, nil } diff --git a/time_test.go b/time_test.go index bd0109b..8f59b83 100644 --- a/time_test.go +++ b/time_test.go @@ -29,6 +29,7 @@ func parseTime(t *testing.T, value string) time.Time { func TestTimeTo(t *testing.T) { tests := []timeTest{ {"2021-01-01T22:00:00+00:00", "2021-01-01T22:00:00+00:00", "just now"}, + {"2021-01-01T22:00:00+00:00", "2021-01-01T22:00:02+00:00", "in a few seconds"}, {"2021-01-01T22:00:00+00:00", "2021-01-02T22:00:00+00:00", "in a day"}, {"2021-01-02T22:00:00+00:00", "2021-01-01T22:00:00+00:00", "a day ago"}, {"2019-01-01T22:00:00+00:00", "2021-01-01T22:00:00+00:00", "in 2 years"},