Skip to content

Commit

Permalink
Use i18n.Locale for date formatting
Browse files Browse the repository at this point in the history
  • Loading branch information
rowanseymour committed Sep 4, 2023
1 parent 6260ba0 commit ac8a7f5
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 143 deletions.
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.

34 changes: 18 additions & 16 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月", "一月", "上午"},
{"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"},
{"zh_CN", "日", "星期日", "1月", "一月", "上午"},
{"zh_HK", "日", "星期日", "1月", "一月", "上午"},
{"zh_SG", "日", "星期日", "一月", "一月", "上午"},
{"zh_TW", "日", "週日", " 1月", "一月", "上午"},
{"zh", "日", "星期日", "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

0 comments on commit ac8a7f5

Please sign in to comment.