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.
This commit is contained in:
dualfroz committed 2026-10-03 02:49:55 +02:00
1 parent c0f903d615
commit fa0fa5b55b
3 files changed
+138

No files matched your search

+10
View File
@@ -103,6 +103,16 @@ fmt.Printf(r.Time.String())
// 13:46:21 (correct) // 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 ### 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. 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.
+84
View File
@@ -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
},
}
}
+44
View File
@@ -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)
}