From 656443749048de8a9fd18565a6d39ec712f7c9d4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 3 Oct 2026 02:49:55 +0200 Subject: [PATCH] feat(rules): add an opt-in timezone rule (#23) common.Timezone matches UTC and GMT with an optional offset (UTC+2, GMT-05:30, GMT+0530) and a small table of upper case abbreviations (EST, PDT, CET, ...), and sets Context.Location, so "10 pm EST" comes back as 22:00 in a -05:00 zone. A timezone alone does not count as a match. It is not added to common.All, so existing parsers behave as before. --- README.md | 10 +++++ rules/common/timezone.go | 84 +++++++++++++++++++++++++++++++++++ rules/common/timezone_test.go | 44 ++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 rules/common/timezone.go create mode 100644 rules/common/timezone_test.go diff --git a/README.md b/README.md index 5fe859a..1e41751 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,16 @@ fmt.Printf(r.Time.String()) // 13:46:21 (correct) ``` +#### Timezones + +The timezone rule is not part of `common.All`, add it to have `10 pm EST` or `22:00 UTC+2` returned in that zone: + +```go +w.Add(common.Timezone(rules.Override)) +``` + +It knows `UTC` and `GMT` with an optional offset (`GMT-05:30`, `UTC+0530`) and a few upper case abbreviations (`EST`, `PDT`, `CET`, ...), each as a fixed offset. + ### State of the project The project is in a more-or-less complete state. It's used for one project already. Bugs will be fixed as soon as they will be found. diff --git a/rules/common/timezone.go b/rules/common/timezone.go new file mode 100644 index 0000000..972331a --- /dev/null +++ b/rules/common/timezone.go @@ -0,0 +1,84 @@ +package common + +import ( + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/olebedev/when/rules" +) + +/* +- 10 pm EST +- 22:00 UTC +- 9am GMT+2 +- 18:30 utc-05:00 +- 18:30 GMT+0530 + +Sets the location of the time found by the other rules. A timezone on its +own is not a time, so the rule never yields a result by itself. + +Abbreviations are matched in upper case only, so "est" in a sentence is +not taken for a timezone, and each one is a fixed offset from UTC. +*/ + +var timezoneOffsets = map[string]time.Duration{ + "HST": -10 * time.Hour, + "AKST": -9 * time.Hour, + "AKDT": -8 * time.Hour, + "PST": -8 * time.Hour, + "PDT": -7 * time.Hour, + "MST": -7 * time.Hour, + "MDT": -6 * time.Hour, + "CST": -6 * time.Hour, + "CDT": -5 * time.Hour, + "EST": -5 * time.Hour, + "EDT": -4 * time.Hour, + "BST": 1 * time.Hour, + "CET": 1 * time.Hour, + "CEST": 2 * time.Hour, + "EET": 2 * time.Hour, + "EEST": 3 * time.Hour, + "JST": 9 * time.Hour, + "AEST": 10 * time.Hour, + "AEDT": 11 * time.Hour, +} + +func Timezone(s rules.Strategy) rules.Rule { + abbreviations := make([]string, 0, len(timezoneOffsets)) + for abbreviation := range timezoneOffsets { + abbreviations = append(abbreviations, abbreviation) + } + sort.Strings(abbreviations) + + return &rules.F{ + RegExp: regexp.MustCompile("(?:\\W|^)" + + "((?i:utc|gmt)(?:([+-])(\\d{1,2})(?::?(\\d{2}))?)?|" + + strings.Join(abbreviations, "|") + ")" + + "(?:\\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 + } + + name := strings.ToUpper(m.Captures[0]) + offset, ok := timezoneOffsets[name] + if !ok { + hours, _ := strconv.Atoi(m.Captures[2]) + minutes, _ := strconv.Atoi(m.Captures[3]) + if hours > 14 || minutes > 59 { + return false, nil + } + offset = time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute + if m.Captures[1] == "-" { + offset = -offset + } + } + + c.Location = time.FixedZone(name, int(offset.Seconds())) + return false, nil + }, + } +} diff --git a/rules/common/timezone_test.go b/rules/common/timezone_test.go new file mode 100644 index 0000000..f51069b --- /dev/null +++ b/rules/common/timezone_test.go @@ -0,0 +1,44 @@ +package common_test + +import ( + "testing" + "time" + + "github.com/olebedev/when" + "github.com/olebedev/when/rules" + "github.com/olebedev/when/rules/common" + "github.com/olebedev/when/rules/en" + "github.com/stretchr/testify/require" +) + +func TestTimezone(t *testing.T) { + fixt := []Fixture{ + {"call me at 10 pm EST", 11, "10 pm EST", 27 * time.Hour}, + {"call me at 10 pm PDT", 11, "10 pm PDT", 29 * time.Hour}, + {"call me at 10 pm CEST", 11, "10 pm CEST", 20 * time.Hour}, + {"at 22:00 UTC", 3, "22:00 UTC", 22 * time.Hour}, + {"at 22:00 utc+2", 3, "22:00 utc+2", 20 * time.Hour}, + {"at 22:00 GMT-05:30", 3, "22:00 GMT-05:30", 27*time.Hour + 30*time.Minute}, + {"at 22:00 GMT+0530", 3, "22:00 GMT+0530", 16*time.Hour + 30*time.Minute}, + + // abbreviations only in upper case, "est" is left alone + {"call me at 10 pm est", 11, "10 pm", 22 * time.Hour}, + } + + w := when.New(nil) + w.Add(en.All...) + w.Add(common.Timezone(rules.Override)) + + ApplyFixtures(t, "common.Timezone", w, fixt) + + nilFixt := []Fixture{ + {"the EST office", 0, "a timezone alone is not a time", 0}, + } + ApplyFixturesNil(t, "common.Timezone nil", w, nilFixt) + + res, err := w.Parse("call me at 10 pm EST", null) + require.Nil(t, err) + name, offset := res.Time.Zone() + require.Equal(t, "EST", name) + require.Equal(t, -5*60*60, offset) +}