Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use i18n.Locale for date formatting #98

Merged
merged 1 commit into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions dates/date.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package dates
import (
"database/sql/driver"
"time"

"github.com/nyaruka/gocommon/i18n"
)

const (
Expand Down Expand Up @@ -48,9 +50,9 @@ func (d Date) Combine(tod TimeOfDay, tz *time.Location) time.Time {
}

// Format formats this date as a string using the given layout
func (d Date) Format(layout, locale string) (string, error) {
func (d Date) Format(layout string, loc i18n.Locale) (string, error) {
// upgrade us to a date time so we can use standard time.Time formatting
return Format(d.Combine(ZeroTimeOfDay, time.UTC), layout, locale, DateOnlyLayouts)
return Format(d.Combine(ZeroTimeOfDay, time.UTC), layout, loc, DateOnlyLayouts)
}

// Weekday returns the day of the week
Expand Down
54 changes: 27 additions & 27 deletions dates/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"time"

"github.com/nyaruka/gocommon/i18n"
"github.com/pkg/errors"
)

Expand Down Expand Up @@ -105,41 +106,40 @@ func ValidateFormat(layout string, type_ LayoutType, mode LayoutMode) error {
//
// If type is DateOnlyLayouts or DateTimeLayouts, the following sequences are accepted:
//
// `YY` - last two digits of year 0-99
// `YYYY` - four digits of your 0000-9999
// `M` - month 1-12
// `MM` - month 01-12
// `MMM` - month Jan-Dec (localized using given locale)
// `MMMM` - month January-December (localized using given locale)
// `D` - day of month 1-31
// `DD` - day of month, zero padded 0-31
// `EEE` - day of week Mon-Sun (localized using given locale)
// `EEEE` - day of week Monday-Sunday (localized using given locale)
// `YY` - last two digits of year 0-99
// `YYYY` - four digits of your 0000-9999
// `M` - month 1-12
// `MM` - month 01-12
// `MMM` - month Jan-Dec (localized using given locale)
// `MMMM` - month January-December (localized using given locale)
// `D` - day of month 1-31
// `DD` - day of month, zero padded 0-31
// `EEE` - day of week Mon-Sun (localized using given locale)
// `EEEE` - day of week Monday-Sunday (localized using given locale)
//
// If type is TimeOnlyLayouts or DateTimeLayouts, the following sequences are accepted:
//
// `h` - hour of the day 1-12
// `hh` - hour of the day 01-12
// `t` - twenty four hour of the day 0-23
// `tt` - twenty four hour of the day 00-23
// `m` - minute 0-59
// `mm` - minute 00-59
// `s` - second 0-59
// `ss` - second 00-59
// `fff` - milliseconds
// `ffffff` - microseconds
// `fffffffff` - nanoseconds
// `aa` - am or pm (localized using given locale)
// `AA` - AM or PM (localized using given locale)
// `h` - hour of the day 1-12
// `hh` - hour of the day 01-12
// `t` - twenty four hour of the day 0-23
// `tt` - twenty four hour of the day 00-23
// `m` - minute 0-59
// `mm` - minute 00-59
// `s` - second 0-59
// `ss` - second 00-59
// `fff` - milliseconds
// `ffffff` - microseconds
// `fffffffff` - nanoseconds
// `aa` - am or pm (localized using given locale)
// `AA` - AM or PM (localized using given locale)
//
// If type is DateTimeLayouts, the following sequences are accepted:
//
// `Z` - hour and minute offset from UTC, or Z for UTC
// `ZZZ` - hour and minute offset from UTC
// `Z` - hour and minute offset from UTC, or Z for UTC
// `ZZZ` - hour and minute offset from UTC
//
// The following chars are allowed and ignored: ' ', ':', ',', 'T', '-', '_', '/'
//
func Format(t time.Time, layout string, locale string, type_ LayoutType) (string, error) {
func Format(t time.Time, layout string, locale i18n.Locale, type_ LayoutType) (string, error) {
output := bytes.Buffer{}

translation := GetTranslation(locale)
Expand Down
92 changes: 46 additions & 46 deletions dates/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"time"

"github.com/nyaruka/gocommon/dates"

"github.com/nyaruka/gocommon/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand All @@ -22,61 +22,61 @@ func TestFormat(t *testing.T) {
tests := []struct {
value time.Time
layout string
locale string
locale i18n.Locale
expected string
err string
}{
{d1, "MM-DD-YYYY", "en-US", "01-02-2006", ""},
{d1, "M-D-YY", "en-US", "1-2-06", ""},
{d1, "h:m", "en-US", "3:4", ""},
{d1, "h:m:s aa", "en-US", "3:4:5 pm", ""},
{d1, "h:m:s AA", "en-US", "3:4:5 PM", ""},
{d1, "tt:mm:ss", "en-US", "15:04:05", ""},
{d2, "tt:mm:ss", "en-US", "09:45:30", ""},
{d2, "t:mm:ss", "en-US", "9:45:30", ""},
{d1, "YYYY-MM-DDTtt:mm:ssZZZ", "en-US", "2006-01-02T15:04:05-07:00", ""},
{d1, "YYYY-MM-DDTtt:mm:ssZZZ", "en-US", "2006-01-02T15:04:05-07:00", ""},
{d1, "YYYY-MM-DDThh:mm:ss.fffZZZ", "en-US", "2006-01-02T03:04:05.123-07:00", ""},
{d1, "YYYY-MM-DDThh:mm:ss.fffZ", "en-US", "2006-01-02T03:04:05.123-07:00", ""},
{d1, "YY-M-D", "en-US", "06-1-2", ""},
{d1, "YYYY-MM-DD", "en-US", "2006-01-02", ""},
{d1, "YYYY-MMM-DD", "en-US", "2006-Jan-02", ""},
{d1, "YYYY-MMMM-DD", "en-US", "2006-January-02", ""},
{d1, "//YY--MM::DD..", "en-US", "//06--01::02..", ""},
{d1, "M-D-YY", "eng-US", "1-2-06", ""},
{d1, "h:m", "eng-US", "3:4", ""},
{d1, "h:m:s aa", "eng-US", "3:4:5 pm", ""},
{d1, "h:m:s AA", "eng-US", "3:4:5 PM", ""},
{d1, "tt:mm:ss", "eng-US", "15:04:05", ""},
{d2, "tt:mm:ss", "eng-US", "09:45:30", ""},
{d2, "t:mm:ss", "eng-US", "9:45:30", ""},
{d1, "YYYY-MM-DDTtt:mm:ssZZZ", "eng-US", "2006-01-02T15:04:05-07:00", ""},
{d1, "YYYY-MM-DDTtt:mm:ssZZZ", "eng-US", "2006-01-02T15:04:05-07:00", ""},
{d1, "YYYY-MM-DDThh:mm:ss.fffZZZ", "eng-US", "2006-01-02T03:04:05.123-07:00", ""},
{d1, "YYYY-MM-DDThh:mm:ss.fffZ", "eng-US", "2006-01-02T03:04:05.123-07:00", ""},
{d1, "YY-M-D", "eng-US", "06-1-2", ""},
{d1, "YYYY-MM-DD", "eng-US", "2006-01-02", ""},
{d1, "YYYY-MMM-DD", "eng-US", "2006-Jan-02", ""},
{d1, "YYYY-MMMM-DD", "eng-US", "2006-January-02", ""},
{d1, "//YY--MM::DD..", "eng-US", "//06--01::02..", ""},

// localization
{d1, "EEE EEEE MMM MMMM AA aa", "en-US", "Mon Monday Jan January PM pm", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "es-EC", "lun lunes ene enero PM pm", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "ar-QA", "ن الاثنين ينا يناير م م", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "ru", "Пн Понедельник янв января PM pm", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "ti", "ሰኑይ ሰኑይ ጥሪ ጥሪ ድሕር ሰዓት ድሕር ሰዓት", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "en-US", "Sat Saturday Apr April AM am", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "es-EC", "sáb sábado abr abril AM am", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "ar-QA", "س السبت أبر أبريل ص ص", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "ru", "Сб Суббота апр апреля AM am", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "ti", "ቀዳም ቀዳም ሚያዝ ሚያዝያ ንጉሆ ሰዓተ ንጉሆ ሰዓተ", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "en-US", "Tue Tuesday Dec December PM pm", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "es-EC", "mar martes dic diciembre PM pm", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "ar-QA", "ث الثلاثاء ديس ديسمبر م م", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "ru", "Вт Вторник дек декабря PM pm", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "ti", "ሰሉስ ሰሉስ ታሕሳ ታሕሳስ ድሕር ሰዓት ድሕር ሰዓት", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "eng-US", "Mon Monday Jan January PM pm", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "spa-EC", "lun lunes ene enero PM pm", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "ara-QA", "ن الاثنين ينا يناير م م", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "rus", "Пн Понедельник янв января PM pm", ""},
{d1, "EEE EEEE MMM MMMM AA aa", "tir", "ሰኑይ ሰኑይ ጃንዩ ጃንዩወሪ ድሕር ሰዓት ድሕር ሰዓት", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "eng-US", "Sat Saturday Apr April AM am", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "spa-EC", "sáb sábado abr abril AM am", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "ara-QA", "س السبت أبر أبريل ص ص", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "rus", "Сб Суббота апр апреля AM am", ""},
{d2, "EEE EEEE MMM MMMM AA aa", "tir", "ቀዳም ቀዳም ኤፕረ ኤፕረል ንጉሆ ሰዓተ ንጉሆ ሰዓተ", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "eng-US", "Tue Tuesday Dec December PM pm", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "spa-EC", "mar martes dic diciembre PM pm", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "ara-QA", "ث الثلاثاء ديس ديسمبر م م", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "rus", "Вт Вторник дек декабря PM pm", ""},
{d3, "EEE EEEE MMM MMMM AA aa", "tir", "ሰሉስ ሰሉስ ዲሴም ዲሴምበር ድሕር ሰዓት ድሕር ሰዓት", ""},

// fractional seconds
{d1, "tt:mm:ss.fff", "en-US", "15:04:05.123", ""},
{d1, "tt:mm:ss.ffffff", "en-US", "15:04:05.123456", ""},
{d1, "tt:mm:ss.fffffffff", "en-US", "15:04:05.123456789", ""},
{d1, "tt:mm:ss.fff", "eng-US", "15:04:05.123", ""},
{d1, "tt:mm:ss.ffffff", "eng-US", "15:04:05.123456", ""},
{d1, "tt:mm:ss.fffffffff", "eng-US", "15:04:05.123456789", ""},

// errors
{d1, "YYY-MM-DD", "en-US", "", "'YYY' is not valid in a datetime formatting layout"},
{d1, "YYYY-MMMMM-DD", "en-US", "", "'MMMMM' is not valid in a datetime formatting layout"},
{d1, "EE", "en-US", "", "'EE' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ss.ffff", "en-US", "", "'ffff' is not valid in a datetime formatting layout"},
{d1, "tt:mmm:ss.ffff", "en-US", "", "'mmm' is not valid in a datetime formatting layout"},
{d1, "tt:mm:sss", "en-US", "", "'sss' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ss a", "en-US", "", "'a' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ss A", "en-US", "", "'A' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ssZZZZ", "en-US", "", "'ZZZZ' is not valid in a datetime formatting layout"},
{d1, "2006-01-02", "en-US", "", "'2' is not valid in a datetime formatting layout"},
{d1, "YYY-MM-DD", "eng-US", "", "'YYY' is not valid in a datetime formatting layout"},
{d1, "YYYY-MMMMM-DD", "eng-US", "", "'MMMMM' is not valid in a datetime formatting layout"},
{d1, "EE", "eng-US", "", "'EE' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ss.ffff", "eng-US", "", "'ffff' is not valid in a datetime formatting layout"},
{d1, "tt:mmm:ss.ffff", "eng-US", "", "'mmm' is not valid in a datetime formatting layout"},
{d1, "tt:mm:sss", "eng-US", "", "'sss' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ss a", "eng-US", "", "'a' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ss A", "eng-US", "", "'A' is not valid in a datetime formatting layout"},
{d1, "tt:mm:ssZZZZ", "eng-US", "", "'ZZZZ' is not valid in a datetime formatting layout"},
{d1, "2006-01-02", "eng-US", "", "'2' is not valid in a datetime formatting layout"},
}

for _, tc := range tests {
Expand Down
65 changes: 16 additions & 49 deletions dates/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ package dates

import (
_ "embed"
"encoding/json"
"sort"

"github.com/nyaruka/gocommon/i18n"
"github.com/nyaruka/gocommon/jsonx"
"golang.org/x/exp/maps"
)

// file containing day and month translations, generated using https://github.com/nyaruka/go-locales
// File containing day and month translations, generated using https://github.com/nyaruka/go-locales
//
// ./localesdump --bcp47 --merge days=LC_TIME.day short_days=LC_TIME.abday months=LC_TIME.mon short_months=LC_TIME.abmon am_pm=LC_TIME.am_pm > dates.json
// ./localesdump --merge days=LC_TIME.day short_days=LC_TIME.abday months=LC_TIME.mon short_months=LC_TIME.abmon am_pm=LC_TIME.am_pm > dates.json
//
//go:embed i18n/i18n.json
//go:embed i18n/dates.json
var i18nJSON []byte

type Translation struct {
Expand All @@ -21,65 +23,30 @@ type Translation struct {
AmPm []string `json:"am_pm"`
}

var bcp47Matcher *i18n.BCP47Matcher
var translations map[string]*Translation
var backdowns = map[string]*Translation{} // language only backdowns for locales that have countries
var defaultLocale = "en-US"
var defaultLocale = "en_US"

func init() {
err := json.Unmarshal(i18nJSON, &translations)
if err != nil {
panic(err)
}
jsonx.MustUnmarshal(i18nJSON, &translations)

bcp47Matcher = i18n.NewBCP47Matcher(maps.Keys(translations)...)

// not all locales have AM/PM values.. but it's simpler if we just given them a default
for _, trans := range translations {
if trans.AmPm[0] == "" {
trans.AmPm = []string{"AM", "PM"}
}
}

// so that we can iterate translations deterministically (code a-z)
codes := make([]string, len(translations))
for c := range translations {
codes = append(codes, c)
}
sort.Strings(codes)

for _, code := range codes {
if len(code) == 5 {
lang := code[:2]
if backdowns[lang] == nil {
backdowns[lang] = translations[code] // using first is arbitary but best we can do
}
}
}
}

// GetTranslation gets the best match translation for the given locale
func GetTranslation(locale string) *Translation {
if locale == "" {
func GetTranslation(loc i18n.Locale) *Translation {
if loc == "" {
return translations[defaultLocale]
}

// try extract xx_YY match
t := translations[locale]
if t != nil {
return t
}

// try match by language xx only
lang := locale[:2]
t = translations[lang]
if t != nil {
return t
}

// use backdown for this language
t = backdowns[lang]
if t != nil {
return t
}
code := bcp47Matcher.ForLocales(loc)

// use default
return translations[defaultLocale]
return translations[code]
}
1 change: 1 addition & 0 deletions dates/i18n/dates.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion dates/i18n/i18n.json

This file was deleted.

36 changes: 19 additions & 17 deletions dates/i18n_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,41 @@ import (
"testing"

"github.com/nyaruka/gocommon/dates"

"github.com/nyaruka/gocommon/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetTranslation(t *testing.T) {
tests := []struct {
locale string
locale i18n.Locale
sun string
sunday string
jan string
january string
am string
}{
{"", "Sun", "Sunday", "Jan", "January", "AM"},
{"en-US", "Sun", "Sunday", "Jan", "January", "AM"},
{"en-GB", "Sun", "Sunday", "Jan", "January", "am"},
{"en", "Sun", "Sunday", "Jan", "January", "am"},
{"es-EC", "dom", "domingo", "ene", "enero", "AM"},
{"es", "dom", "domingo", "ene", "enero", "AM"},
{"pt-BR", "dom", "domingo", "jan", "janeiro", "AM"},
{"pt-PT", "dom", "domingo", "jan", "janeiro", "AM"},
{"pt", "dom", "domingo", "jan", "janeiro", "AM"},
{"rw-RW", "Mwe", "Ku cyumweru", "Mut", "Mutarama", "AM"},
{"rw", "Mwe", "Ku cyumweru", "Mut", "Mutarama", "AM"},
{"zh-CN", "日", "星期日", "1月", "一月", "上午"},
{"zh-HK", "日", "星期日", "1月", "一月", "上午"},
{"zh-SG", "日", "星期日", "一月", "一月", "上午"},
{"zh-TW", "日", "週日", " 1月", "一月", "上午"},
{"zh", "日", "星期日", "1月", "一月", "上午"}, // backs down to first zh translation
{"eng-US", "Sun", "Sunday", "Jan", "January", "AM"},
{"eng-GB", "Sun", "Sunday", "Jan", "January", "am"},
{"eng", "Sun", "Sunday", "Jan", "January", "AM"},
{"spa-EC", "dom", "domingo", "ene", "enero", "AM"},
{"spa", "dom", "domingo", "ene", "enero", "AM"},
{"por-BR", "dom", "domingo", "jan", "janeiro", "AM"},
{"por-PT", "dom", "domingo", "jan", "janeiro", "AM"},
{"por", "dom", "domingo", "jan", "janeiro", "AM"},
{"kin-RW", "Mwe", "Ku cyumweru", "Mut", "Mutarama", "AM"},
{"kin", "Mwe", "Ku cyumweru", "Mut", "Mutarama", "AM"},
{"zho-CN", "日", "星期日", "1月", "一月", "上午"},
{"zho-HK", "日", "星期日", "1月", "一月", "上午"},
{"zho-SG", "日", "星期日", "一月", "一月", "上午"},
{"zho-TW", "日", "週日", " 1月", "一月", "上午"},
{"zho", "日", "星期日", "1月", "一月", "上午"}, // backs down to first zh translation
}

for _, tc := range tests {
trans := dates.GetTranslation(tc.locale)
require.NotNil(t, trans, "trans unexpectedly nil for local '%s'", tc.locale)
assert.Equal(t, tc.sun, trans.ShortDays[0], "short day mismatch for locale %s", tc.locale)
assert.Equal(t, tc.sunday, trans.Days[0], "full day mismatch for locale %s", tc.locale)
assert.Equal(t, tc.jan, trans.ShortMonths[0], "short month mismatch for locale %s", tc.locale)
Expand Down
6 changes: 4 additions & 2 deletions dates/timeofday.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package dates

import (
"time"

"github.com/nyaruka/gocommon/i18n"
)

const (
Expand Down Expand Up @@ -51,9 +53,9 @@ func (t TimeOfDay) Combine(date Date, tz *time.Location) time.Time {
}

// Format formats this time of day as a string
func (t TimeOfDay) Format(layout, locale string) (string, error) {
func (t TimeOfDay) Format(layout string, loc i18n.Locale) (string, error) {
// upgrade us to a date time so we can use standard time.Time formatting
return Format(t.Combine(ZeroDate, time.UTC), layout, locale, TimeOnlyLayouts)
return Format(t.Combine(ZeroDate, time.UTC), layout, loc, TimeOnlyLayouts)
}

// String returns the ISO8601 representation
Expand Down
Loading