From 6668ec63f7bc136de7973bbf7f6aa876ed6553fe Mon Sep 17 00:00:00 2001 From: Adam Magued Date: Wed, 14 Oct 2026 04:01:59 +0000 Subject: [PATCH] feat(rules/en): add timezone parsing rule for common timezones and offsets --- rules/en/en.go | 5 +++ rules/en/timezone.go | 84 +++++++++++++++++++++++++++++++++++++++ rules/en/timezone_test.go | 40 +++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 rules/en/timezone.go create mode 100644 rules/en/timezone_test.go diff --git a/rules/en/en.go b/rules/en/en.go index 0b9bd9d..8bbd2ca 100644 --- a/rules/en/en.go +++ b/rules/en/en.go @@ -11,6 +11,7 @@ var All = []rules.Rule{ Deadline(rules.Override), PastTime(rules.Override), ExactMonthDate(rules.Override), + Timezone(rules.Override), } var WEEKDAY_OFFSET = map[string]int{ @@ -167,3 +168,7 @@ var ORDINAL_WORDS = map[string]int{ } var ORDINAL_WORDS_PATTERN = `(?:1st|first|2nd|second|3rd|third|4th|fourth|5th|fifth|6th|sixth|7th|seventh|8th|eighth|9th|ninth|10th|tenth|11th|eleventh|12th|twelfth|13th|thirteenth|14th|fourteenth|15th|fifteenth|16th|sixteenth|17th|seventeenth|18th|eighteenth|19th|nineteenth|20th|twentieth|21st|twenty[ -]first|22nd|twenty[ -]second|23rd|twenty[ -]third|24th|twenty[ -]fourth|25th|twenty[ -]fifth|26th|twenty[ -]sixth|27th|twenty[ -]seventh|28th|twenty[ -]eighth|29th|twenty[ -]ninth|30th|thirtieth|31st|thirty[ -]first)` + +func init() { + // append Timezone rule if needed or include in All +} diff --git a/rules/en/timezone.go b/rules/en/timezone.go new file mode 100644 index 0000000..a9e8827 --- /dev/null +++ b/rules/en/timezone.go @@ -0,0 +1,84 @@ +package en + +import ( + "errors" + "regexp" + "strings" + "time" + + "github.com/olebedev/when/rules" +) + +func Timezone(s rules.Strategy) rules.Rule { + return &rules.F{ + RegExp: regexp.MustCompile("(?i)(?:\\W|^)" + + "((?:UTC|GMT|EST|EDT|CST|CDT|MST|MDT|PST|PDT|[+-]\\d{2}:?\\d{2}))" + + "(?:\\W|$)"), + Applier: func(m *rules.Match, c *rules.Context, o *rules.Options, ref time.Time) (bool, error) { + if c.Location != nil && s != rules.Override { + return false, nil + } + + tzStr := m.Captures[0] + loc, err := parseTimezone(tzStr) + if err != nil { + return false, nil + } + + c.Location = loc + return true, nil + }, + } +} + +func parseTimezone(tz string) (*time.Location, error) { + upper := strings.ToUpper(tz) + switch upper { + case "UTC", "GMT": + return time.UTC, nil + case "EST": + return time.FixedZone("EST", -5*3600), nil + case "EDT": + return time.FixedZone("EDT", -4*3600), nil + case "CST": + return time.FixedZone("CST", -6*3600), nil + case "CDT": + return time.FixedZone("CDT", -5*3600), nil + case "MST": + return time.FixedZone("MST", -7*3600), nil + case "MDT": + return time.FixedZone("MDT", -6*3600), nil + case "PST": + return time.FixedZone("PST", -8*3600), nil + case "PDT": + return time.FixedZone("PDT", -7*3600), nil + } + + loc, err := time.LoadLocation(tz) + if err == nil { + return loc, nil + } + + return parseNumericOffset(tz) +} + +func parseNumericOffset(tz string) (*time.Location, error) { + re := regexp.MustCompile(`^([+-])(\d{2}):?(\d{2})?$`) + matches := re.FindStringSubmatch(tz) + if matches == nil { + return nil, errors.New("invalid timezone offset") + } + + sign := matches[1] + hours, _ := time.ParseDuration(matches[2] + "h") + var minutes time.Duration + if matches[3] != "" { + minutes, _ = time.ParseDuration(matches[3] + "m") + } + offset := int(hours + minutes) + if sign == "-" { + offset = -offset + } + + return time.FixedZone(tz, offset), nil +} diff --git a/rules/en/timezone_test.go b/rules/en/timezone_test.go new file mode 100644 index 0000000..c39f399 --- /dev/null +++ b/rules/en/timezone_test.go @@ -0,0 +1,40 @@ +package en + +import ( + "testing" + "time" + + "github.com/olebedev/when/rules" + "github.com/stretchr/testify/assert" +) + +func TestTimezone(t *testing.T) { + rule := Timezone(rules.Override) + ref := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + + tests := []struct { + text string + expectedZone string + matched bool + }{ + {"10 PM EST", "EST", true}, + {"10:30am PDT", "PDT", true}, + {"tomorrow at 5pm UTC", "UTC", true}, + {"12:00 +02:00", "+02:00", true}, + {"invalid", "", false}, + } + + for _, tc := range tests { + m := rule.Find(tc.text) + if !tc.matched { + assert.Nil(t, m) + continue + } + assert.NotNil(t, m) + ctx := &rules.Context{} + ok, err := m.Apply(ctx, nil, ref) + assert.NoError(t, err) + assert.True(t, ok) + assert.NotNil(t, ctx.Location) + } +}