From 408941cee105d2c97cf732662ed0bdea1733b309 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 27 Jul 2024 15:45:30 -0500 Subject: [PATCH 01/12] initial commit --- .idea/.gitignore | 8 + .idea/modules.xml | 8 + .idea/pixlet.iml | 9 + .idea/vcs.xml | 6 + runtime/modules/icalendar/icalendar.go | 46 +++++ runtime/modules/icalendar/icalendar_helper.go | 33 ++++ .../modules/icalendar/parser/members/line.go | 43 +++++ .../icalendar/parser/members/line_test.go | 77 +++++++++ .../modules/icalendar/parser/members/rrule.go | 7 + runtime/modules/icalendar/parser/parser.go | 68 ++++++++ runtime/modules/icalendar/parser/types.go | 163 ++++++++++++++++++ 11 files changed, 468 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/modules.xml create mode 100644 .idea/pixlet.iml create mode 100644 .idea/vcs.xml create mode 100644 runtime/modules/icalendar/icalendar.go create mode 100644 runtime/modules/icalendar/icalendar_helper.go create mode 100644 runtime/modules/icalendar/parser/members/line.go create mode 100644 runtime/modules/icalendar/parser/members/line_test.go create mode 100644 runtime/modules/icalendar/parser/members/rrule.go create mode 100644 runtime/modules/icalendar/parser/parser.go create mode 100644 runtime/modules/icalendar/parser/types.go diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..13566b81b0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..fd56b43318 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pixlet.iml b/.idea/pixlet.iml new file mode 100644 index 0000000000..5e764c4f0b --- /dev/null +++ b/.idea/pixlet.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/runtime/modules/icalendar/icalendar.go b/runtime/modules/icalendar/icalendar.go new file mode 100644 index 0000000000..fa03b160ff --- /dev/null +++ b/runtime/modules/icalendar/icalendar.go @@ -0,0 +1,46 @@ +package icalendar + +import ( + "sync" + "time" + + godfe "github.com/newm4n/go-dfe" + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" +) + +const ( + ModuleName = "icalendar" +) + +var ( + once sync.Once + module starlark.StringDict + empty time.Time + translation *godfe.PatternTranslation +) + +func LoadModule() (starlark.StringDict, error) { + translation = godfe.NewPatternTranslation() + once.Do(func() { + module = starlark.StringDict{ + ModuleName: &starlarkstruct.Module{ + Name: ModuleName, + Members: starlark.StringDict{ + "time": starlark.NewBuiltin("time", times), + "findNextEvent": starlark.NewBuiltin("findNextEvent", findNextEvent), + }, + }, + } + }) + + return module, nil +} + +func times(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + return starlark.False, nil +} + +func findNextEvent(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + return starlark.False, nil +} diff --git a/runtime/modules/icalendar/icalendar_helper.go b/runtime/modules/icalendar/icalendar_helper.go new file mode 100644 index 0000000000..0949e33c08 --- /dev/null +++ b/runtime/modules/icalendar/icalendar_helper.go @@ -0,0 +1,33 @@ +package icalendar + +import ( + "bufio" + "net/http" +) + +type ICalendar struct { + url string + data *bufio.Scanner +} + +func NewICalendar(url string) *ICalendar { + return &ICalendar{ + url: url, + data: nil, + } +} + +func (c *ICalendar) GetCalendar() error { + data, err := http.Get(c.url) + if err != nil { + return err + } + + c.data = bufio.NewScanner(data.Body) + + return err +} + +func (c *ICalendar) ParseCalendar() error { + return nil +} diff --git a/runtime/modules/icalendar/parser/members/line.go b/runtime/modules/icalendar/parser/members/line.go new file mode 100644 index 0000000000..5f7150a936 --- /dev/null +++ b/runtime/modules/icalendar/parser/members/line.go @@ -0,0 +1,43 @@ +package members + +import "strings" + +func ParseRecurrenceParams(p string) (string, map[string]string) { + tokens := strings.Split(p, ";") + + parameters := make(map[string]string) + for _, p = range tokens { + t := strings.Split(p, "=") + if len(t) != 2 { + continue + } + parameters[t[0]] = t[1] + } + + return tokens[0], parameters +} + +func ParseParameters(p string) (string, map[string]string) { + tokens := strings.Split(p, ";") + + parameters := make(map[string]string) + + for _, p = range tokens[1:] { + t := strings.Split(p, "=") + if len(t) != 2 { + continue + } + + parameters[t[0]] = t[1] + } + + return tokens[0], parameters +} + +func UnescapeString(l string) string { + l = strings.Replace(l, `\\`, `\`, -1) + l = strings.Replace(l, `\;`, `;`, -1) + l = strings.Replace(l, `\,`, `,`, -1) + + return l +} diff --git a/runtime/modules/icalendar/parser/members/line_test.go b/runtime/modules/icalendar/parser/members/line_test.go new file mode 100644 index 0000000000..3fbadfcee3 --- /dev/null +++ b/runtime/modules/icalendar/parser/members/line_test.go @@ -0,0 +1,77 @@ +package members + +import ( + "reflect" + "testing" +) + +func TestParseParameters(t *testing.T) { + type args struct { + p string + } + tests := []struct { + name string + args args + want string + want1 map[string]string + }{ + {"Test1", args{p: "HELLO;KEY1=value1;KEY2=value2"}, "HELLO", map[string]string{"KEY1": "value1", "KEY2": "value2"}}, + {"Test2", args{p: "TEST2;GAS=FUEL;ROCK=STONE"}, "TEST2", map[string]string{"GAS": "FUEL", "ROCK": "STONE"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := ParseParameters(tt.args.p) + if got != tt.want { + t.Errorf("ParseParameters() got = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got1, tt.want1) { + t.Errorf("ParseParameters() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func TestParseRecurrenceParams(t *testing.T) { + type args struct { + p string + } + tests := []struct { + name string + args args + want string + want1 map[string]string + }{ + {"Test1", args{p: "RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;COUNT=10"}, "RRULE", map[string]string{"FREQ": "WEEKLY", "COUNT": "10", "WKST": "SU", "INTERVAL": "2"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := ParseRecurrenceParams(tt.args.p) + if got != tt.want { + t.Errorf("ParseRecurrenceParams() got = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got1, tt.want1) { + t.Errorf("ParseRecurrenceParams() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func TestUnescapeString(t *testing.T) { + type args struct { + l string + } + tests := []struct { + name string + args args + want string + }{ + {"Test1", args{l: `Hello\, world\; lorem \\ipsum.`}, `Hello, world; lorem \ipsum.`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := UnescapeString(tt.args.l); got != tt.want { + t.Errorf("UnescapeString() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/runtime/modules/icalendar/parser/members/rrule.go b/runtime/modules/icalendar/parser/members/rrule.go new file mode 100644 index 0000000000..075ae04b32 --- /dev/null +++ b/runtime/modules/icalendar/parser/members/rrule.go @@ -0,0 +1,7 @@ +package members + +func ParseRecurrenceRule(v string) (map[string]string, error) { + _, params := ParseRecurrenceParams(v) + + return params, nil +} diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go new file mode 100644 index 0000000000..d8269cb155 --- /dev/null +++ b/runtime/modules/icalendar/parser/parser.go @@ -0,0 +1,68 @@ +package parser + +import ( + "bufio" + "io" + "strings" + "time" +) + +func NewParser(r io.Reader) *Calendar { + return &Calendar{ + scanner: bufio.NewScanner(r), + Events: make([]*Event, 0), + Strict: StrictParams{ + Mode: StrictModeFailFeed, + }, + Duplicate: DuplicateParams{ + DuplicateModeFailStrict, + }, + SkipBounds: false, + AllDayEventsTZ: time.UTC, + } +} + +/* +* The iCal mandates that lines longer than 75 octets require a linebreak. +* The format uses progressive whitespace indentation to denote a line is continued on a new line. +* https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html + */ +func (cal *Calendar) findNextLine() string { + l := cal.scanner.Text() + done := !cal.scanner.Scan() + + if !done { + for strings.HasPrefix(cal.scanner.Text(), " ") { + l = l + strings.TrimPrefix(cal.scanner.Text(), " ") + if done = !cal.scanner.Scan(); done { + break + } + } + } + + return l +} + +// splitLineTokens assures that property parameters that are quoted due to containing special +// characters (like COLON, SEMICOLON, COMMA) are not split. +// See RFC5545, 3.1.1. +func splitLineTokens(line string) []string { + // go's Split is highly optimized -> use, unless we cannot + if idxQuote := strings.Index(line, `"`); idxQuote == -1 { + return strings.SplitN(line, ":", 2) + } else if idxColon := strings.Index(line, ":"); idxQuote > idxColon { + return []string{line[0:idxColon], line[idxColon+1:]} + } + + // otherwise, we need to do it ourselves, let's keep it simple at least: + quoted := false + size := len(line) + for idx, char := range []byte(line) { + if char == '"' { + quoted = !quoted + } else if char == ':' && !quoted && idx+1 < size { + return []string{line[0:idx], line[idx+1:]} + } + } + return []string{line} +} diff --git a/runtime/modules/icalendar/parser/types.go b/runtime/modules/icalendar/parser/types.go new file mode 100644 index 0000000000..2fb445dbe8 --- /dev/null +++ b/runtime/modules/icalendar/parser/types.go @@ -0,0 +1,163 @@ +package parser + +import ( + "bufio" + "fmt" + "strings" + "time" +) + +const ( + StrictModeFailFeed = iota + StrictModeFailAttribute + StrictModeFailEvent +) + +const ( + DuplicateModeFailStrict = iota + DuplicateModeKeepFirst + DuplicateModeKeepLast +) + +type StrictParams struct { + Mode int +} + +type DuplicateParams struct { + Mode int +} + +type DuplicateAttributeError struct { + Key, Value string +} + +func NewDuplicateAttributeError(key string, value string) DuplicateAttributeError { + return DuplicateAttributeError{key, value} +} + +func (err DuplicateAttributeError) Error() string { + return fmt.Sprintf("duplicate attribute '%s': %s", err.Key, err.Value) +} + +type Calendar struct { + scanner *bufio.Scanner + Events []*Event + SkipBounds bool + Strict StrictParams + Duplicate DuplicateParams + buffer *Event + Start *time.Time + End *time.Time + Method string + AllDayEventsTZ *time.Location +} + +func (cal *Calendar) IsInRange(d Event) bool { + if (d.Start.Before(*cal.Start) && d.End.After(*cal.Start)) || + (d.Start.After(*cal.Start) && d.End.Before(*cal.End)) || + (d.Start.Before(*cal.End) && d.End.After(*cal.End)) { + return true + } + return false +} + +const ( + ContextRoot = iota + ContextEvent + ContextUnknown +) + +type Context struct { + value int +} + +func (ctx *Context) Nest() *Context { + return &Context{ctx.value} +} + +type RawDate struct { + Params map[string]string + Value string +} + +type Line struct { + Key string + Params map[string]string + Value string +} + +func (l *Line) Is(key, value string) bool { + if strings.TrimSpace(l.Key) == key && strings.TrimSpace(l.Value) == value { + return true + } + + return false +} + +func (l *Line) IsKey(key, value string) bool { + return strings.TrimSpace(l.Key) == key +} + +func (l *Line) IsValue(key, value string) bool { + return strings.TrimSpace(l.Value) == value +} + +type Event struct { + delayed []*Line + + Uid string + Summary string + Description string + Categories []string + Start *time.Time + End *time.Time + RawStart *RawDate + RawEnd *RawDate + Duration *time.Duration + Stamp *time.Time + Created *time.Time + LastModified *time.Time + Location string + LatLng LatLng + Url string + Status string + Organizer *Organizer + Attendees []*Attendee + Attachments []*Attachment + IsRecurring bool + RecurrenceId string + RecurrenceRule map[string]string + ExcludeDates []*time.Time + Sequence int + CustomAttributes []*time.Time + Valid bool + Comment string + Class string +} + +type Organizer struct { + Cn string + DirectoryDn string + Value string +} + +type Attendee struct { + Cn string + DirectoryDn string + Status string + Value string + CustomAttributes map[string]string +} + +type Attachment struct { + Encoding string + Type string + Mime string + Filename string + Value string +} + +type LatLng struct { + lat string + long string +} From 2456723ab3fdc53efc548a19866e50b90d7066d6 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sun, 28 Jul 2024 21:04:16 -0500 Subject: [PATCH 02/12] feat: add rrule to ical parser --- go.mod | 1 + go.sum | 2 + .../modules/icalendar/parser/members/time.go | 111 +++++++++++++ .../parser/members/timezone_conversion.go | 152 ++++++++++++++++++ runtime/modules/icalendar/parser/resolver.go | 71 ++++++++ runtime/modules/icalendar/parser/rrule.go | 29 ++++ runtime/modules/icalendar/parser/types.go | 65 ++++---- 7 files changed, 399 insertions(+), 32 deletions(-) create mode 100644 runtime/modules/icalendar/parser/members/time.go create mode 100644 runtime/modules/icalendar/parser/members/timezone_conversion.go create mode 100644 runtime/modules/icalendar/parser/resolver.go create mode 100644 runtime/modules/icalendar/parser/rrule.go diff --git a/go.mod b/go.mod index 57aaff64c9..3c96c420b2 100644 --- a/go.mod +++ b/go.mod @@ -93,6 +93,7 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect go.uber.org/atomic v1.9.0 // indirect diff --git a/go.sum b/go.sum index dda5ac51fc..1a1b27b5cb 100644 --- a/go.sum +++ b/go.sum @@ -324,6 +324,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/tidbyt/gg v0.0.0-20220808163829-95806fa1d427 h1:br5WYVw/jr4G0PZpBBx2fBAANVUrI8KKHMSs3LVqO9A= github.com/tidbyt/gg v0.0.0-20220808163829-95806fa1d427/go.mod h1:+SCm6iJHe2lfsQzlbLCsd5XsTKYSD0VqtQmWMnNs9OE= github.com/tidbyt/go-libwebp v0.0.0-20230922075150-fb11063b2a6a h1:zvAhEO3ZB7m1Lc3BwJXLTDrLrHVAbcDByJ7XkL4WR+s= diff --git a/runtime/modules/icalendar/parser/members/time.go b/runtime/modules/icalendar/parser/members/time.go new file mode 100644 index 0000000000..4052faa2ad --- /dev/null +++ b/runtime/modules/icalendar/parser/members/time.go @@ -0,0 +1,111 @@ +package members + +import ( + cases "golang.org/x/text/cases" + "golang.org/x/text/language" + "strings" + "time" +) + +const ( + TimeStart = iota + TimeEnd +) + +func ParseTime(s string, params map[string]string, ty int, allday bool, allDayTZ *time.Location) (*time.Time, error) { + // Date field is YYYYMMDD + // Reference: https://icalendar.org/iCalendar-RFC-5545/3-3-4-date.html + if params["VALUE"] == "DATE" || len(s) == 8 { + t, err := time.Parse("20060102", s) + if err != nil { + return nil, err + } + + if ty == TimeStart { + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, allDayTZ) + + } else if ty == TimeEnd { + if allday { + t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999, allDayTZ) + } else { + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, allDayTZ) + } + } + return &t, err + } + + // Z indicates we're using UTC + if strings.HasSuffix(s, "Z") { + format := "20060102T150405Z" + tz, _ := time.LoadLocation("UTC") + t, err := time.ParseInLocation(format, s, tz) + if err != nil { + return nil, err + } + return &t, err + } else if params["TZID"] != "" { + format := "20060102T150405" + tz, err := time.LoadLocation(params["TZID"]) + if err != nil { + // If there's an error we can assume that the timezones are in Window's format. + // This is especially common due to Microsoft Exchange iCalendar files + unixTz, err := ConvertTimeZoneWindowsToLinux(params["TZID"]) + if err != nil { + return nil, err + } + tz, err := time.LoadLocation(*unixTz) + if err != nil { + return nil, err + } + t, err := time.ParseInLocation(format, s, tz) + if err != nil { + return nil, err + } + return &t, err + } + + t, err := time.ParseInLocation(format, s, tz) + if err != nil { + return nil, err + } + return &t, err + } + + // Default to local time if Z is not in use, there's no TZID and there is no Date + format := "20060102T150405" + tz := time.Local + + t, err := time.ParseInLocation(format, s, tz) + if err != nil { + return nil, err + } + return &t, err +} + +func ParseDuration(s string) (*time.Duration, error) { + dur, err := time.ParseDuration(s) + if err != nil { + return nil, err + } + + return &dur, err +} + +func LoadTimezone(tzid string) (*time.Location, error) { + tz, err := time.LoadLocation(tzid) + if err != nil { + return tz, nil + } + + tokens := strings.Split(tzid, "_") + for idx, token := range tokens { + t := strings.ToLower(token) + if t != "of" && t != "es" { + tokens[idx] = cases.Title(language.English).String(token) + } else { + tokens[idx] = t + } + } + + return time.LoadLocation(strings.Join(tokens, "_")) +} diff --git a/runtime/modules/icalendar/parser/members/timezone_conversion.go b/runtime/modules/icalendar/parser/members/timezone_conversion.go new file mode 100644 index 0000000000..d3a858a0dc --- /dev/null +++ b/runtime/modules/icalendar/parser/members/timezone_conversion.go @@ -0,0 +1,152 @@ +package members + +import "errors" + +func ConvertTimeZoneWindowsToLinux(wtzid string) (*string, error) { + conversionMap := map[string]string{ + "Dateline Standard Time": "Etc/GMT+12", + "UTC-11": "Etc/GMT+11", + "Aleutian Standard Time": "America/Adak", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Marquesas Standard Time": "Pacific/Marquesas", + "Alaskan Standard Time": "America/Anchorage", + "UTC-09": "Etc/GMT+9", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "UTC-08": "Etc/GMT+8", + "Pacific Standard Time": "America/Los_Angeles", + "US Mountain Standard Time": "America/Phoenix", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Mountain Standard Time": "America/Denver", + "Central America Standard Time": "America/Guatemala", + "Central Standard Time": "America/Chicago", + "Easter Island Standard Time": "Pacific/Easter", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Canada Central Standard Time": "America/Regina", + "SA Pacific Standard Time": "America/Bogota", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Eastern Standard Time": "America/New_York", + "Haiti Standard Time": "America/Port-au-Prince", + "Cuba Standard Time": "America/Havana", + "US Eastern Standard Time": "America/Indiana/Indianapolis", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "Paraguay Standard Time": "America/Asuncion", + "Atlantic Standard Time": "America/Halifax", + "Venezuela Standard Time": "America/Caracas", + "Central Brazilian Standard Time": "America/Cuiaba", + "SA Western Standard Time": "America/La_Paz", + "Pacific SA Standard Time": "America/Santiago", + "Newfoundland Standard Time": "America/St_Johns", + "Tocantins Standard Time": "America/Araguaina", + "E. South America Standard Time": "America/Sao_Paulo", + "SA Eastern Standard Time": "America/Cayenne", + "Argentina Standard Time": "America/Argentina/Buenos_Aires", + "Greenland Standard Time": "America/Godthab", + "Montevideo Standard Time": "America/Montevideo", + "Magallanes Standard Time": "America/Punta_Arenas", + "Saint Pierre Standard Time": "America/Miquelon", + "Bahia Standard Time": "America/Bahia", + "UTC-02": "Etc/GMT+2", + "Mid-Atlantic Standard Time": "Etc/GMT+2", + "Azores Standard Time": "Atlantic/Azores", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "UTC": "Etc/UTC", + "Morocco Standard Time": "Africa/Casablanca", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Central European Standard Time": "Europe/Warsaw", + "W. Central Africa Standard Time": "Africa/Lagos", + "Jordan Standard Time": "Asia/Amman", + "GTB Standard Time": "Europe/Bucharest", + "Middle East Standard Time": "Asia/Beirut", + "Egypt Standard Time": "Africa/Cairo", + "E. Europe Standard Time": "Europe/Chisinau", + "Syria Standard Time": "Asia/Damascus", + "West Bank Standard Time": "Asia/Hebron", + "South Africa Standard Time": "Africa/Johannesburg", + "FLE Standard Time": "Europe/Kiev", + "Israel Standard Time": "Asia/Jerusalem", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Sudan Standard Time": "Africa/Khartoum", + "Libya Standard Time": "Africa/Tripoli", + "Namibia Standard Time": "Africa/Windhoek", + "Arabic Standard Time": "Asia/Baghdad", + "Turkey Standard Time": "Europe/Istanbul", + "Arab Standard Time": "Asia/Riyadh", + "Belarus Standard Time": "Europe/Minsk", + "Russian Standard Time": "Europe/Moscow", + "E. Africa Standard Time": "Africa/Nairobi", + "Iran Standard Time": "Asia/Tehran", + "Arabian Standard Time": "Asia/Dubai", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Azerbaijan Standard Time": "Asia/Baku", + "Russia Time Zone 3": "Europe/Samara", + "Mauritius Standard Time": "Indian/Mauritius", + "Saratov Standard Time": "Europe/Saratov", + "Georgian Standard Time": "Asia/Tbilisi", + "Caucasus Standard Time": "Asia/Yerevan", + "Afghanistan Standard Time": "Asia/Kabul", + "West Asia Standard Time": "Asia/Tashkent", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "Pakistan Standard Time": "Asia/Karachi", + "India Standard Time": "Asia/Kolkata", + "Sri Lanka Standard Time": "Asia/Colombo", + "Nepal Standard Time": "Asia/Kathmandu", + "Central Asia Standard Time": "Asia/Almaty", + "Bangladesh Standard Time": "Asia/Dhaka", + "Omsk Standard Time": "Asia/Omsk", + "Myanmar Standard Time": "Asia/Yangon", + "SE Asia Standard Time": "Asia/Bangkok", + "Altai Standard Time": "Asia/Barnaul", + "W. Mongolia Standard Time": "Asia/Hovd", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Tomsk Standard Time": "Asia/Tomsk", + "China Standard Time": "Asia/Shanghai", + "North Asia East Standard Time": "Asia/Irkutsk", + "Singapore Standard Time": "Asia/Singapore", + "W. Australia Standard Time": "Australia/Perth", + "Taipei Standard Time": "Asia/Taipei", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "North Korea Standard Time": "Asia/Pyongyang", + "Aus Central W. Standard Time": "Australia/Eucla", + "Transbaikal Standard Time": "Asia/Chita", + "Tokyo Standard Time": "Asia/Tokyo", + "Korea Standard Time": "Asia/Seoul", + "Yakutsk Standard Time": "Asia/Yakutsk", + "Cen. Australia Standard Time": "Australia/Adelaide", + "AUS Central Standard Time": "Australia/Darwin", + "E. Australia Standard Time": "Australia/Brisbane", + "AUS Eastern Standard Time": "Australia/Sydney", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Tasmania Standard Time": "Australia/Hobart", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Bougainville Standard Time": "Pacific/Bougainville", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Magadan Standard Time": "Asia/Magadan", + "Norfolk Standard Time": "Pacific/Norfolk", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Russia Time Zone 11": "Asia/Kamchatka", + "New Zealand Standard Time": "Pacific/Auckland", + "UTC+12": "Etc/GMT-12", + "Fiji Standard Time": "Pacific/Fiji", + "Kamchatka Standard Time": "Asia/Kamchatka", + "Chatham Islands Standard Time": "Pacific/Chatham", + "UTC+13": "Etc/GMT-13", + "Tonga Standard Time": "Pacific/Tongatapu", + "Samoa Standard Time": "Pacific/Apia", + "Line Islands Standard Time": "Pacific/Kiritimati", + } + + val, ok := conversionMap[wtzid] + if !ok { + return nil, errors.New("invalid windows timezone id") + } + + return &val, nil +} diff --git a/runtime/modules/icalendar/parser/resolver.go b/runtime/modules/icalendar/parser/resolver.go new file mode 100644 index 0000000000..e43038ed3d --- /dev/null +++ b/runtime/modules/icalendar/parser/resolver.go @@ -0,0 +1,71 @@ +package parser + +import ( + "fmt" + "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members" + "time" +) + +func resolve[T comparable](cal *Calendar, l *Line, dst *T, resolve func(cal *Calendar, line *Line) (T, T, error), post func(cal *Calendar, out T)) error { + value, empty, err := resolve(cal, l) + if err != nil { + return err + } + + if dst != nil && *dst != empty { + if cal.Duplicate.Mode == DuplicateModeFailStrict { + return NewDuplicateAttribute(l.Key, l.Value) + } + } + + // If the value is empty or the duplicate mode allows further processing, set the value + if *dst == empty || cal.Duplicate.Mode == DuplicateModeKeepLast { + *dst = value + if post != nil && dst != nil { + post(cal, *dst) + } + } + + return nil +} + +func resolveString(cal *Calendar, l *Line) (string, string, error) { + return l.Value, "", nil +} + +func resolveDate(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { + d, err := members.ParseTime(l.Value, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) + if err != nil { + return nil, nil, fmt.Errorf("could not parse: %s", err) + } + + return d, nil, nil +} + +func resolveDateEnd(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { + d, err := members.ParseTime(l.Value, l.Params, members.TimeEnd, false, cal.AllDayEventsTZ) + if err != nil { + return nil, nil, fmt.Errorf("could not parse: %s", err) + } + + return d, nil, nil +} + +func resolveOrganizer(cal *Calendar, l *Line) (*Organizer, *Organizer, error) { + o := Organizer{ + Cn: l.Params["CN"], + DirectoryDn: l.Params["DIR"], + Value: l.Value, + } + + return &o, nil, nil +} + +func resolveDuration(cal *Calendar, l *Line) (*time.Duration, *time.Duration, error) { + d, err := members.ParseDuration(l.Value) + if err != nil { + return nil, nil, fmt.Errorf("could not parse: %s", err) + } + + return d, nil, nil +} diff --git a/runtime/modules/icalendar/parser/rrule.go b/runtime/modules/icalendar/parser/rrule.go new file mode 100644 index 0000000000..d6e692983c --- /dev/null +++ b/runtime/modules/icalendar/parser/rrule.go @@ -0,0 +1,29 @@ +package parser + +import ( + "github.com/teambition/rrule-go" + "time" +) + +func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { + rule, err := rrule.StrToRRule(buf.RawRecurrenceRule) + if err != nil { + return []Event{} + } + + allRecurrences := rule.All() + + var expandedEvents []Event + for _, rec := range allRecurrences { + e := *buf + + newEnd := time.Date(rec.Year(), rec.Month(), rec.Day(), buf.End.Hour(), buf.End.Minute(), rec.Second(), buf.End.Nanosecond(), time.UTC) + + e.Start = &rec + e.End = &newEnd + + expandedEvents = append(expandedEvents, e) + } + + return expandedEvents +} diff --git a/runtime/modules/icalendar/parser/types.go b/runtime/modules/icalendar/parser/types.go index 2fb445dbe8..7cdadbc752 100644 --- a/runtime/modules/icalendar/parser/types.go +++ b/runtime/modules/icalendar/parser/types.go @@ -27,15 +27,15 @@ type DuplicateParams struct { Mode int } -type DuplicateAttributeError struct { +type DuplicateAttribute struct { Key, Value string } -func NewDuplicateAttributeError(key string, value string) DuplicateAttributeError { - return DuplicateAttributeError{key, value} +func NewDuplicateAttribute(key string, value string) DuplicateAttribute { + return DuplicateAttribute{key, value} } -func (err DuplicateAttributeError) Error() string { +func (err DuplicateAttribute) Error() string { return fmt.Sprintf("duplicate attribute '%s': %s", err.Key, err.Value) } @@ -105,34 +105,35 @@ func (l *Line) IsValue(key, value string) bool { type Event struct { delayed []*Line - Uid string - Summary string - Description string - Categories []string - Start *time.Time - End *time.Time - RawStart *RawDate - RawEnd *RawDate - Duration *time.Duration - Stamp *time.Time - Created *time.Time - LastModified *time.Time - Location string - LatLng LatLng - Url string - Status string - Organizer *Organizer - Attendees []*Attendee - Attachments []*Attachment - IsRecurring bool - RecurrenceId string - RecurrenceRule map[string]string - ExcludeDates []*time.Time - Sequence int - CustomAttributes []*time.Time - Valid bool - Comment string - Class string + Uid string + Summary string + Description string + Categories []string + Start *time.Time + End *time.Time + RawStart *RawDate + RawEnd *RawDate + Duration *time.Duration + Stamp *time.Time + Created *time.Time + LastModified *time.Time + Location string + LatLng LatLng + Url string + Status string + Organizer *Organizer + Attendees []*Attendee + Attachments []*Attachment + IsRecurring bool + RecurrenceId string + RecurrenceRule map[string]string + RawRecurrenceRule string + ExcludeDates []*time.Time + Sequence int + CustomAttributes []*time.Time + Valid bool + Comment string + Class string } type Organizer struct { From 08f4634424628301aa32cc719a93ca433d138299 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Fri, 16 Aug 2024 19:35:45 -0700 Subject: [PATCH 03/12] finish parser --- .../icalendar/parser/members/latlng.go | 24 ++ runtime/modules/icalendar/parser/parser.go | 345 +++++++++++++++++- runtime/modules/icalendar/parser/resolver.go | 9 + runtime/modules/icalendar/parser/types.go | 42 ++- 4 files changed, 404 insertions(+), 16 deletions(-) create mode 100644 runtime/modules/icalendar/parser/members/latlng.go diff --git a/runtime/modules/icalendar/parser/members/latlng.go b/runtime/modules/icalendar/parser/members/latlng.go new file mode 100644 index 0000000000..4c2dbba54b --- /dev/null +++ b/runtime/modules/icalendar/parser/members/latlng.go @@ -0,0 +1,24 @@ +package members + +import ( + "fmt" + "strconv" + "strings" +) + +func ParseLatLng(l string) (float64, float64, error) { + token := strings.SplitN(l, ";", 2) + if len(token) != 2 { + return 0.0, 0.0, fmt.Errorf("could not parse geo coordinates: %s", l) + } + lat, laterr := strconv.ParseFloat(token[0], 64) + if laterr != nil { + return 0.0, 0.0, fmt.Errorf("could not parse geo latitude: %s", token[0]) + } + long, longerr := strconv.ParseFloat(token[1], 64) + if longerr != nil { + return 0.0, 0.0, fmt.Errorf("could not parse geo longitude: %s", token[1]) + } + + return lat, long, nil +} diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go index d8269cb155..7972bb30a6 100644 --- a/runtime/modules/icalendar/parser/parser.go +++ b/runtime/modules/icalendar/parser/parser.go @@ -2,8 +2,12 @@ package parser import ( "bufio" + "errors" + "fmt" "io" + "strconv" "strings" + "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members" "time" ) @@ -22,12 +26,142 @@ func NewParser(r io.Reader) *Calendar { } } +func (cal *Calendar) Parse() error { + if cal.Start == nil { + start := time.Now().Add(-1 * 24 * time.Hour) + cal.Start = &start + } + if cal.End == nil { + end := time.Now().Add(3 * 30 * 24 * time.Hour) + cal.End = &end + } + + cal.scanner.Scan() + + rInstances := make([]Event, 0) + ctx := &Context{Value: ContextRoot} + + for { + l, err, done := cal.parseLine() + if err != nil { + if done { + break + } + continue + } + + if l.IsValue("VCALENDAR") { + continue + } + + if ctx.Value == ContextRoot && l.Is("BEGIN", "VEVENT") { + ctx = ctx.Nest(ContextEvent) + cal.buffer = &Event{Valid: true, delayed: make([]*Line, 0)} + } else if ctx.Value == ContextEvent && l.Is("END", "VEVENT") { + if ctx.Previous == nil { + return fmt.Errorf("got an END:* without matching BEGIN:*") + } + ctx = ctx.Previous + + for _, d := range cal.buffer.delayed { + cal.parseEvent(d) + } + + if cal.buffer.RawStart.Value == cal.buffer.RawEnd.Value { + if value, ok := cal.buffer.RawEnd.Params["VALUE"]; ok && value == "DATE" { + cal.buffer.End, err = members.ParseTime(cal.buffer.RawEnd.Value, cal.buffer.RawEnd.Params, members.TimeEnd, true, cal.AllDayEventsTZ) + } + } + + if cal.buffer.End == nil && cal.buffer.RawStart.Params["VALUE"] == "DATE" { + d := (*cal.buffer.Start).Add(24 * time.Hour) + cal.buffer.End = &d + } + + if err := cal.checkEvent(); err != nil { + switch cal.Strict.Mode { + case StrictModeFailFeed: + return fmt.Errorf("calender error: %s", err) + case StrictModeFailEvent: + continue + } + } + + if cal.buffer.Start == nil || cal.buffer.End == nil { + continue + } + + if cal.buffer.IsRecurring { + rInstances = append(rInstances, cal.ExpandRecurringEvent(cal.buffer)...) + } else { + if cal.buffer.End == nil || cal.buffer.Start == nil { + continue + } + if !cal.SkipBounds && !cal.IsInRange(*cal.buffer) { + continue + } + if cal.Strict.Mode == StrictModeFailEvent && !cal.buffer.Valid { + continue + } + cal.Events = append(cal.Events, cal.buffer) + } + + } else if l.IsKey("BEGIN") { + ctx = ctx.Nest(ContextUnknown) + + } else if l.IsKey("END") { + if ctx.Previous == nil { + return fmt.Errorf("got an END:* without matching BEGIN:*") + } + + ctx = ctx.Previous + } else if ctx.Value == ContextEvent { + if err := cal.parseEvent(l); err != nil { + var duplicateAttributeError DuplicateAttributeError + if errors.As(err, &duplicateAttributeError) { + switch cal.Duplicate.Mode { + case DuplicateModeFailStrict: + switch cal.Strict.Mode { + case StrictModeFailFeed: + return fmt.Errorf("gocal error: %s", err) + case StrictModeFailEvent: + cal.buffer.Valid = false + continue + case StrictModeFailAttribute: + cal.buffer.Valid = false + continue + } + } + } + + return fmt.Errorf(fmt.Sprintf("gocal error: %s", err)) + + } + } else { + continue + } + + if done { + break + } + } + + for _, i := range rInstances { + if !cal.IsRecurringInstanceOverridden(&i) && cal.IsInRange(i) { + cal.Events = append(cal.Events, &i) + } + } + + return nil + +} + /* * The iCal mandates that lines longer than 75 octets require a linebreak. * The format uses progressive whitespace indentation to denote a line is continued on a new line. * https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html */ -func (cal *Calendar) findNextLine() string { +func (cal *Calendar) findNextLine() (bool, string) { l := cal.scanner.Text() done := !cal.scanner.Scan() @@ -40,7 +174,7 @@ func (cal *Calendar) findNextLine() string { } } - return l + return done, l } // splitLineTokens assures that property parameters that are quoted due to containing special @@ -66,3 +200,210 @@ func splitLineTokens(line string) []string { } return []string{line} } + +func (cal *Calendar) parseLine() (*Line, error, bool) { + done, line := cal.findNextLine() + if done { + return nil, nil, done + } + tokens := splitLineTokens(line) + if len(tokens) < 2 { + return nil, fmt.Errorf("could not parse item: %s", line), done + } + + attr, params := members.ParseParameters(tokens[0]) + + return &Line{Key: attr, Params: params, Value: members.UnescapeString(strings.TrimPrefix(tokens[1], " "))}, nil, done +} + +func (cal *Calendar) parseEvent(l *Line) error { + + if cal.buffer == nil { + return nil + } + + switch l.Key { + case "UID": + if err := resolve(cal, l, &cal.buffer.Uid, resolveString, nil); err != nil { + return err + } + case "SUMMARY": + if err := resolve(cal, l, &cal.buffer.Summary, resolveString, nil); err != nil { + return err + } + + case "DESCRIPTION": + if err := resolve(cal, l, &cal.buffer.Description, resolveString, nil); err != nil { + return err + } + case "DTSTART": + if err := resolve(cal, l, &cal.buffer.Start, resolveDate, func(cal *Calendar, out *time.Time) { + cal.buffer.RawStart = &RawDate{Value: l.Value, Params: l.Params} + }); err != nil { + return err + } + + case "DTEND": + if err := resolve(cal, l, &cal.buffer.End, resolveDateEnd, func(cal *Calendar, out *time.Time) { + cal.buffer.RawEnd = &RawDate{Value: l.Value, Params: l.Params} + }); err != nil { + return err + } + case "DURATION": + /* + * Duration should be parsed in conjunction with DTSTART + * If DTSTART has not been processed, we add to delayed attributes for processing last + */ + if cal.buffer.Start == nil { + cal.buffer.delayed = append(cal.buffer.delayed, l) + return nil + } + + if err := resolve(cal, l, &cal.buffer.Duration, resolveDuration, func(cal *Calendar, out *time.Duration) { + if out != nil { + cal.buffer.Duration = out + end := cal.buffer.Start.Add(*out) + cal.buffer.End = &end + } + }); err != nil { + return err + } + + case "DTSTAMP": + if err := resolve(cal, l, &cal.buffer.Stamp, resolveDate, nil); err != nil { + return err + } + + case "CREATED": + if err := resolve(cal, l, &cal.buffer.Created, resolveDate, nil); err != nil { + return err + } + + case "LAST-MODIFIED": + if err := resolve(cal, l, &cal.buffer.LastModified, resolveDate, nil); err != nil { + return err + } + case "RRULE": + if len(cal.buffer.RecurrenceRule) != 0 { + return NewDuplicateAttribute(l.Key, l.Value) + } + if cal.buffer.RecurrenceRule == nil || cal.Duplicate.Mode == DuplicateModeKeepLast { + var err error + + cal.buffer.IsRecurring = true + + if cal.buffer.RecurrenceRule, err = members.ParseRecurrenceRule(l.Value); err != nil { + return err + } + } + case "RECURRENCE-ID": + if err := resolve(cal, l, &cal.buffer.RecurrenceId, resolveString, nil); err != nil { + return err + } + case "EXDATE": + /* + * Reference: https://icalendar.org/iCalendar-RFC-5545/3-8-5-1-exception-date-times.html + * Several parameters are allowed. We should pass parameters we have + */ + + d, err := members.ParseTime(l.Value, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) + if err == nil { + cal.buffer.ExcludeDates = append(cal.buffer.ExcludeDates, d) + } + + case "SEQUENCE": + cal.buffer.Sequence, _ = strconv.Atoi(l.Value) + case "LOCATION": + if err := resolve(cal, l, &cal.buffer.Location, resolveString, nil); err != nil { + return err + } + + case "STATUS": + if err := resolve(cal, l, &cal.buffer.Status, resolveString, nil); err != nil { + return err + } + case "ORGANIZER": + if err := resolve(cal, l, &cal.buffer.Organizer, resolveOrganizer, nil); err != nil { + return err + } + + case "ATTENDEE": + attendee := &Attendee{ + Value: l.Value, + } + + for key, val := range l.Params { + key := strings.ToUpper(key) + switch key { + case "CN": + attendee.Cn = val + case "DIR": + attendee.DirectoryDn = val + + case "PARTSTAT": + attendee.Status = val + + default: + if strings.HasPrefix(key, "X-") { + if attendee.CustomAttributes == nil { + attendee.CustomAttributes = make(map[string]string) + } + attendee.CustomAttributes[key] = val + } + } + } + cal.buffer.Attendees = append(cal.buffer.Attendees, attendee) + case "ATTACH": + cal.buffer.Attachments = append(cal.buffer.Attachments, &Attachment{ + Type: l.Params["VALUE"], + Encoding: l.Params["ENCODING"], + Mime: l.Params["FMTTYPE"], + Filename: l.Params["FILENAME"], + Value: l.Value, + }) + + case "GEO": + if err := resolve(cal, l, &cal.buffer.LatLng, resolveLatLng, nil); err != nil { + return err + } + case "CATEGORIES": + cal.buffer.Categories = strings.Split(l.Value, ",") + case "URL": + cal.buffer.Url = l.Value + case "COMMENT": + cal.buffer.Comment = l.Value + case "CLASS": + cal.buffer.Class = l.Value + default: + key := strings.ToUpper(l.Key) + if strings.HasPrefix(key, "X-") { + if cal.buffer.CustomAttributes == nil { + cal.buffer.CustomAttributes = make(map[string]string) + } + cal.buffer.CustomAttributes[key] = l.Value + } + + } + + return nil +} + +func (cal *Calendar) checkEvent() error { + if cal.buffer.Uid == "" { + cal.buffer.Valid = false + return fmt.Errorf("could not parse event without UID") + } + if cal.buffer.Start == nil { + cal.buffer.Valid = false + return fmt.Errorf("could not parse event without DTSTART") + } + if cal.buffer.Stamp == nil { + cal.buffer.Valid = false + return fmt.Errorf("could not parse event without DTSTAMP") + } + if cal.buffer.RawEnd.Value != "" && cal.buffer.Duration != nil { + return fmt.Errorf("only one of DTEND and DURATION must be provided") + } + + return nil +} diff --git a/runtime/modules/icalendar/parser/resolver.go b/runtime/modules/icalendar/parser/resolver.go index e43038ed3d..2e67f502d3 100644 --- a/runtime/modules/icalendar/parser/resolver.go +++ b/runtime/modules/icalendar/parser/resolver.go @@ -33,6 +33,15 @@ func resolveString(cal *Calendar, l *Line) (string, string, error) { return l.Value, "", nil } +func resolveLatLng(gc *Calendar, l *Line) (*LatLng, *LatLng, error) { + lat, long, err := members.ParseLatLng(l.Value) + if err != nil { + return nil, nil, err + } + + return &LatLng{lat, long}, nil, nil +} + func resolveDate(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { d, err := members.ParseTime(l.Value, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) if err != nil { diff --git a/runtime/modules/icalendar/parser/types.go b/runtime/modules/icalendar/parser/types.go index 7cdadbc752..e30b3e3506 100644 --- a/runtime/modules/icalendar/parser/types.go +++ b/runtime/modules/icalendar/parser/types.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "strings" + "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members" "time" ) @@ -27,16 +28,16 @@ type DuplicateParams struct { Mode int } -type DuplicateAttribute struct { +type DuplicateAttributeError struct { Key, Value string } -func NewDuplicateAttribute(key string, value string) DuplicateAttribute { - return DuplicateAttribute{key, value} +func NewDuplicateAttribute(k, v string) DuplicateAttributeError { + return DuplicateAttributeError{Key: k, Value: v} } -func (err DuplicateAttribute) Error() string { - return fmt.Sprintf("duplicate attribute '%s': %s", err.Key, err.Value) +func (err DuplicateAttributeError) Error() string { + return fmt.Sprintf("duplicate attribute %s: %s", err.Key, err.Value) } type Calendar struct { @@ -68,11 +69,12 @@ const ( ) type Context struct { - value int + Value int + Previous *Context } -func (ctx *Context) Nest() *Context { - return &Context{ctx.value} +func (ctx *Context) Nest(value int) *Context { + return &Context{Value: value, Previous: ctx} } type RawDate struct { @@ -94,11 +96,11 @@ func (l *Line) Is(key, value string) bool { return false } -func (l *Line) IsKey(key, value string) bool { +func (l *Line) IsKey(key string) bool { return strings.TrimSpace(l.Key) == key } -func (l *Line) IsValue(key, value string) bool { +func (l *Line) IsValue(value string) bool { return strings.TrimSpace(l.Value) == value } @@ -118,7 +120,7 @@ type Event struct { Created *time.Time LastModified *time.Time Location string - LatLng LatLng + LatLng *LatLng Url string Status string Organizer *Organizer @@ -130,7 +132,7 @@ type Event struct { RawRecurrenceRule string ExcludeDates []*time.Time Sequence int - CustomAttributes []*time.Time + CustomAttributes map[string]string Valid bool Comment string Class string @@ -159,6 +161,18 @@ type Attachment struct { } type LatLng struct { - lat string - long string + lat float64 + long float64 +} + +func (cal *Calendar) IsRecurringInstanceOverridden(instance *Event) bool { + for _, e := range cal.Events { + if e.Uid == instance.Uid { + rid, _ := members.ParseTime(e.RecurrenceId, map[string]string{}, members.TimeStart, false, cal.AllDayEventsTZ) + if rid.Equal(*instance.Start) { + return true + } + } + } + return false } From b72fd1f7b72410c1f585233e36172a2b7d13f301 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Fri, 16 Aug 2024 22:46:58 -0700 Subject: [PATCH 04/12] work in progress --- runtime/modules/icalendar/icalendar.go | 94 ++++++++++++++++++- runtime/modules/icalendar/icalendar_helper.go | 33 ------- runtime/modules/icalendar/parser/parser.go | 4 +- runtime/modules/icalendar/parser/rrule.go | 4 +- 4 files changed, 96 insertions(+), 39 deletions(-) delete mode 100644 runtime/modules/icalendar/icalendar_helper.go diff --git a/runtime/modules/icalendar/icalendar.go b/runtime/modules/icalendar/icalendar.go index fa03b160ff..e60d519f35 100644 --- a/runtime/modules/icalendar/icalendar.go +++ b/runtime/modules/icalendar/icalendar.go @@ -1,7 +1,11 @@ package icalendar import ( + "fmt" + "net/url" + "strings" "sync" + "tidbyt.dev/pixlet/runtime/modules/icalendar/parser" "time" godfe "github.com/newm4n/go-dfe" @@ -27,7 +31,6 @@ func LoadModule() (starlark.StringDict, error) { ModuleName: &starlarkstruct.Module{ Name: ModuleName, Members: starlark.StringDict{ - "time": starlark.NewBuiltin("time", times), "findNextEvent": starlark.NewBuiltin("findNextEvent", findNextEvent), }, }, @@ -37,10 +40,95 @@ func LoadModule() (starlark.StringDict, error) { return module, nil } -func times(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - return starlark.False, nil +/* +* This function returns a list of events with the events metadata + */ +func parseCalendar(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var ( + rawCalendar starlark.String + ) + if err := starlark.UnpackArgs( + "parseCalendar", + args, kwargs, + "str", &rawCalendar, + ); err != nil { + return nil, fmt.Errorf("unpacking arguments for bytes: %s", err) + } + calendar := parser.NewParser(strings.NewReader(rawCalendar.GoString())) + if err := calendar.Parse(); err != nil { + return nil, fmt.Errorf("parsing calendar: %s", err) + } + + events := make([]starlark.Value, 0, len(calendar.Events)) + for _, event := range calendar.Events { + dict := starlark.NewDict(25) + + if err := dict.SetKey(starlark.String("uid"), starlark.String(event.Uid)); err != nil { + return nil, fmt.Errorf("setting uid: %s", err) + } + if err := dict.SetKey(starlark.String("summary"), starlark.String(event.Summary)); err != nil { + return nil, fmt.Errorf("setting summary: %s", err) + } + if err := dict.SetKey(starlark.String("description"), starlark.String(event.Description)); err != nil { + return nil, fmt.Errorf("setting description: %s", err) + } + if err := dict.SetKey(starlark.String("status"), starlark.String(event.Status)); err != nil { + return nil, fmt.Errorf("setting status: %s", err) + } + if err := dict.SetKey(starlark.String("comment"), starlark.String(event.Comment)); err != nil { + return nil, fmt.Errorf("setting comment: %s", err) + } + if err := dict.SetKey(starlark.String("start"), starlark.String(event.Start.Format(time.RFC3339))); err != nil { + return nil, fmt.Errorf("setting start: %s", err) + } + if err := dict.SetKey(starlark.String("end"), starlark.String(event.End.Format(time.RFC3339))); err != nil { + return nil, fmt.Errorf("setting end: %s", err) + } + if err := dict.SetKey(starlark.String("is_recurring"), starlark.Bool(event.IsRecurring)); err != nil { + return nil, fmt.Errorf("setting is_recurring: %s", err) + } + if err := dict.SetKey(starlark.String("location"), starlark.String(event.Location)); err != nil { + return nil, fmt.Errorf("setting location: %s", err) + } + if err := dict.SetKey(starlark.String("duration_in_seconds"), starlark.Float(event.Duration.Seconds())); err != nil { + return nil, fmt.Errorf("setting duration: %s", err) + } + if err := dict.SetKey(starlark.String("created_at"), starlark.String(event.Created.Format(time.RFC3339))); err != nil { + return nil, fmt.Errorf("setting end: %s", err) + } + if err := dict.SetKey(starlark.String("updated_at"), starlark.String(event.LastModified.Format(time.RFC3339))); err != nil { + return nil, fmt.Errorf("setting end: %s", err) + } + if err := dict.SetKey(starlark.String("url"), starlark.String(event.Url)); err != nil { + return nil, fmt.Errorf("setting end: %s", err) + } + if err := dict.SetKey(starlark.String("sequence"), starlark.MakeInt(event.Sequence)); err != nil { + return nil, fmt.Errorf("setting end: %s", err) + } + + events = append(events, dict) + } + return starlark.NewList(events), nil } func findNextEvent(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { return starlark.False, nil } + +func urlEncode(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var ( + starUrl starlark.String + ) + + if err := starlark.UnpackArgs( + "url_encode", + args, kwargs, + "str", &starUrl, + ); err != nil { + return nil, fmt.Errorf("unpacking arguments for bytes: %s", err) + } + + escapedUrl := url.QueryEscape(starUrl.GoString()) + + return starlark.String(escapedUrl), nil +} diff --git a/runtime/modules/icalendar/icalendar_helper.go b/runtime/modules/icalendar/icalendar_helper.go deleted file mode 100644 index 0949e33c08..0000000000 --- a/runtime/modules/icalendar/icalendar_helper.go +++ /dev/null @@ -1,33 +0,0 @@ -package icalendar - -import ( - "bufio" - "net/http" -) - -type ICalendar struct { - url string - data *bufio.Scanner -} - -func NewICalendar(url string) *ICalendar { - return &ICalendar{ - url: url, - data: nil, - } -} - -func (c *ICalendar) GetCalendar() error { - data, err := http.Get(c.url) - if err != nil { - return err - } - - c.data = bufio.NewScanner(data.Body) - - return err -} - -func (c *ICalendar) ParseCalendar() error { - return nil -} diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go index 7972bb30a6..3c3dc4ad10 100644 --- a/runtime/modules/icalendar/parser/parser.go +++ b/runtime/modules/icalendar/parser/parser.go @@ -303,9 +303,9 @@ func (cal *Calendar) parseEvent(l *Line) error { case "EXDATE": /* * Reference: https://icalendar.org/iCalendar-RFC-5545/3-8-5-1-exception-date-times.html - * Several parameters are allowed. We should pass parameters we have + * Several parameters are allowed. We should pass parameters we have */ - + // @TODO NEEDS to be fixed for multiple ex dates d, err := members.ParseTime(l.Value, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) if err == nil { cal.buffer.ExcludeDates = append(cal.buffer.ExcludeDates, d) diff --git a/runtime/modules/icalendar/parser/rrule.go b/runtime/modules/icalendar/parser/rrule.go index d6e692983c..d83669e52b 100644 --- a/runtime/modules/icalendar/parser/rrule.go +++ b/runtime/modules/icalendar/parser/rrule.go @@ -5,9 +5,11 @@ import ( "time" ) +// @TODO: Remove cancelled events; event can be canelled at the top level or have individual recurrences cancelled + func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { rule, err := rrule.StrToRRule(buf.RawRecurrenceRule) - if err != nil { + if err != nil || buf.Status == "CANCELLED" { return []Event{} } From d4eeb57b9695a880bfd2b327886e70c0e0d43070 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 14:00:35 -0700 Subject: [PATCH 05/12] feat: update exdate to handle multiple dates per rfc --- .../parser/members/timezone_conversion.go | 282 +++++++++--------- runtime/modules/icalendar/parser/parser.go | 12 +- runtime/modules/icalendar/parser/resolver.go | 20 ++ runtime/modules/icalendar/parser/rrule.go | 4 +- 4 files changed, 170 insertions(+), 148 deletions(-) diff --git a/runtime/modules/icalendar/parser/members/timezone_conversion.go b/runtime/modules/icalendar/parser/members/timezone_conversion.go index d3a858a0dc..a69485d3aa 100644 --- a/runtime/modules/icalendar/parser/members/timezone_conversion.go +++ b/runtime/modules/icalendar/parser/members/timezone_conversion.go @@ -2,148 +2,148 @@ package members import "errors" -func ConvertTimeZoneWindowsToLinux(wtzid string) (*string, error) { - conversionMap := map[string]string{ - "Dateline Standard Time": "Etc/GMT+12", - "UTC-11": "Etc/GMT+11", - "Aleutian Standard Time": "America/Adak", - "Hawaiian Standard Time": "Pacific/Honolulu", - "Marquesas Standard Time": "Pacific/Marquesas", - "Alaskan Standard Time": "America/Anchorage", - "UTC-09": "Etc/GMT+9", - "Pacific Standard Time (Mexico)": "America/Tijuana", - "UTC-08": "Etc/GMT+8", - "Pacific Standard Time": "America/Los_Angeles", - "US Mountain Standard Time": "America/Phoenix", - "Mountain Standard Time (Mexico)": "America/Chihuahua", - "Mountain Standard Time": "America/Denver", - "Central America Standard Time": "America/Guatemala", - "Central Standard Time": "America/Chicago", - "Easter Island Standard Time": "Pacific/Easter", - "Central Standard Time (Mexico)": "America/Mexico_City", - "Canada Central Standard Time": "America/Regina", - "SA Pacific Standard Time": "America/Bogota", - "Eastern Standard Time (Mexico)": "America/Cancun", - "Eastern Standard Time": "America/New_York", - "Haiti Standard Time": "America/Port-au-Prince", - "Cuba Standard Time": "America/Havana", - "US Eastern Standard Time": "America/Indiana/Indianapolis", - "Turks And Caicos Standard Time": "America/Grand_Turk", - "Paraguay Standard Time": "America/Asuncion", - "Atlantic Standard Time": "America/Halifax", - "Venezuela Standard Time": "America/Caracas", - "Central Brazilian Standard Time": "America/Cuiaba", - "SA Western Standard Time": "America/La_Paz", - "Pacific SA Standard Time": "America/Santiago", - "Newfoundland Standard Time": "America/St_Johns", - "Tocantins Standard Time": "America/Araguaina", - "E. South America Standard Time": "America/Sao_Paulo", - "SA Eastern Standard Time": "America/Cayenne", - "Argentina Standard Time": "America/Argentina/Buenos_Aires", - "Greenland Standard Time": "America/Godthab", - "Montevideo Standard Time": "America/Montevideo", - "Magallanes Standard Time": "America/Punta_Arenas", - "Saint Pierre Standard Time": "America/Miquelon", - "Bahia Standard Time": "America/Bahia", - "UTC-02": "Etc/GMT+2", - "Mid-Atlantic Standard Time": "Etc/GMT+2", - "Azores Standard Time": "Atlantic/Azores", - "Cape Verde Standard Time": "Atlantic/Cape_Verde", - "UTC": "Etc/UTC", - "Morocco Standard Time": "Africa/Casablanca", - "GMT Standard Time": "Europe/London", - "Greenwich Standard Time": "Atlantic/Reykjavik", - "W. Europe Standard Time": "Europe/Berlin", - "Central Europe Standard Time": "Europe/Budapest", - "Romance Standard Time": "Europe/Paris", - "Sao Tome Standard Time": "Africa/Sao_Tome", - "Central European Standard Time": "Europe/Warsaw", - "W. Central Africa Standard Time": "Africa/Lagos", - "Jordan Standard Time": "Asia/Amman", - "GTB Standard Time": "Europe/Bucharest", - "Middle East Standard Time": "Asia/Beirut", - "Egypt Standard Time": "Africa/Cairo", - "E. Europe Standard Time": "Europe/Chisinau", - "Syria Standard Time": "Asia/Damascus", - "West Bank Standard Time": "Asia/Hebron", - "South Africa Standard Time": "Africa/Johannesburg", - "FLE Standard Time": "Europe/Kiev", - "Israel Standard Time": "Asia/Jerusalem", - "Kaliningrad Standard Time": "Europe/Kaliningrad", - "Sudan Standard Time": "Africa/Khartoum", - "Libya Standard Time": "Africa/Tripoli", - "Namibia Standard Time": "Africa/Windhoek", - "Arabic Standard Time": "Asia/Baghdad", - "Turkey Standard Time": "Europe/Istanbul", - "Arab Standard Time": "Asia/Riyadh", - "Belarus Standard Time": "Europe/Minsk", - "Russian Standard Time": "Europe/Moscow", - "E. Africa Standard Time": "Africa/Nairobi", - "Iran Standard Time": "Asia/Tehran", - "Arabian Standard Time": "Asia/Dubai", - "Astrakhan Standard Time": "Europe/Astrakhan", - "Azerbaijan Standard Time": "Asia/Baku", - "Russia Time Zone 3": "Europe/Samara", - "Mauritius Standard Time": "Indian/Mauritius", - "Saratov Standard Time": "Europe/Saratov", - "Georgian Standard Time": "Asia/Tbilisi", - "Caucasus Standard Time": "Asia/Yerevan", - "Afghanistan Standard Time": "Asia/Kabul", - "West Asia Standard Time": "Asia/Tashkent", - "Ekaterinburg Standard Time": "Asia/Yekaterinburg", - "Pakistan Standard Time": "Asia/Karachi", - "India Standard Time": "Asia/Kolkata", - "Sri Lanka Standard Time": "Asia/Colombo", - "Nepal Standard Time": "Asia/Kathmandu", - "Central Asia Standard Time": "Asia/Almaty", - "Bangladesh Standard Time": "Asia/Dhaka", - "Omsk Standard Time": "Asia/Omsk", - "Myanmar Standard Time": "Asia/Yangon", - "SE Asia Standard Time": "Asia/Bangkok", - "Altai Standard Time": "Asia/Barnaul", - "W. Mongolia Standard Time": "Asia/Hovd", - "North Asia Standard Time": "Asia/Krasnoyarsk", - "N. Central Asia Standard Time": "Asia/Novosibirsk", - "Tomsk Standard Time": "Asia/Tomsk", - "China Standard Time": "Asia/Shanghai", - "North Asia East Standard Time": "Asia/Irkutsk", - "Singapore Standard Time": "Asia/Singapore", - "W. Australia Standard Time": "Australia/Perth", - "Taipei Standard Time": "Asia/Taipei", - "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", - "North Korea Standard Time": "Asia/Pyongyang", - "Aus Central W. Standard Time": "Australia/Eucla", - "Transbaikal Standard Time": "Asia/Chita", - "Tokyo Standard Time": "Asia/Tokyo", - "Korea Standard Time": "Asia/Seoul", - "Yakutsk Standard Time": "Asia/Yakutsk", - "Cen. Australia Standard Time": "Australia/Adelaide", - "AUS Central Standard Time": "Australia/Darwin", - "E. Australia Standard Time": "Australia/Brisbane", - "AUS Eastern Standard Time": "Australia/Sydney", - "West Pacific Standard Time": "Pacific/Port_Moresby", - "Tasmania Standard Time": "Australia/Hobart", - "Vladivostok Standard Time": "Asia/Vladivostok", - "Lord Howe Standard Time": "Australia/Lord_Howe", - "Bougainville Standard Time": "Pacific/Bougainville", - "Russia Time Zone 10": "Asia/Srednekolymsk", - "Magadan Standard Time": "Asia/Magadan", - "Norfolk Standard Time": "Pacific/Norfolk", - "Sakhalin Standard Time": "Asia/Sakhalin", - "Central Pacific Standard Time": "Pacific/Guadalcanal", - "Russia Time Zone 11": "Asia/Kamchatka", - "New Zealand Standard Time": "Pacific/Auckland", - "UTC+12": "Etc/GMT-12", - "Fiji Standard Time": "Pacific/Fiji", - "Kamchatka Standard Time": "Asia/Kamchatka", - "Chatham Islands Standard Time": "Pacific/Chatham", - "UTC+13": "Etc/GMT-13", - "Tonga Standard Time": "Pacific/Tongatapu", - "Samoa Standard Time": "Pacific/Apia", - "Line Islands Standard Time": "Pacific/Kiritimati", - } +var WindowsTimezones = map[string]string{ + "Dateline Standard Time": "Etc/GMT+12", + "UTC-11": "Etc/GMT+11", + "Aleutian Standard Time": "America/Adak", + "Hawaiian Standard Time": "Pacific/Honolulu", + "Marquesas Standard Time": "Pacific/Marquesas", + "Alaskan Standard Time": "America/Anchorage", + "UTC-09": "Etc/GMT+9", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "UTC-08": "Etc/GMT+8", + "Pacific Standard Time": "America/Los_Angeles", + "US Mountain Standard Time": "America/Phoenix", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Mountain Standard Time": "America/Denver", + "Central America Standard Time": "America/Guatemala", + "Central Standard Time": "America/Chicago", + "Easter Island Standard Time": "Pacific/Easter", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Canada Central Standard Time": "America/Regina", + "SA Pacific Standard Time": "America/Bogota", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Eastern Standard Time": "America/New_York", + "Haiti Standard Time": "America/Port-au-Prince", + "Cuba Standard Time": "America/Havana", + "US Eastern Standard Time": "America/Indiana/Indianapolis", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "Paraguay Standard Time": "America/Asuncion", + "Atlantic Standard Time": "America/Halifax", + "Venezuela Standard Time": "America/Caracas", + "Central Brazilian Standard Time": "America/Cuiaba", + "SA Western Standard Time": "America/La_Paz", + "Pacific SA Standard Time": "America/Santiago", + "Newfoundland Standard Time": "America/St_Johns", + "Tocantins Standard Time": "America/Araguaina", + "E. South America Standard Time": "America/Sao_Paulo", + "SA Eastern Standard Time": "America/Cayenne", + "Argentina Standard Time": "America/Argentina/Buenos_Aires", + "Greenland Standard Time": "America/Godthab", + "Montevideo Standard Time": "America/Montevideo", + "Magallanes Standard Time": "America/Punta_Arenas", + "Saint Pierre Standard Time": "America/Miquelon", + "Bahia Standard Time": "America/Bahia", + "UTC-02": "Etc/GMT+2", + "Mid-Atlantic Standard Time": "Etc/GMT+2", + "Azores Standard Time": "Atlantic/Azores", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "UTC": "Etc/UTC", + "Morocco Standard Time": "Africa/Casablanca", + "GMT Standard Time": "Europe/London", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "W. Europe Standard Time": "Europe/Berlin", + "Central Europe Standard Time": "Europe/Budapest", + "Romance Standard Time": "Europe/Paris", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Central European Standard Time": "Europe/Warsaw", + "W. Central Africa Standard Time": "Africa/Lagos", + "Jordan Standard Time": "Asia/Amman", + "GTB Standard Time": "Europe/Bucharest", + "Middle East Standard Time": "Asia/Beirut", + "Egypt Standard Time": "Africa/Cairo", + "E. Europe Standard Time": "Europe/Chisinau", + "Syria Standard Time": "Asia/Damascus", + "West Bank Standard Time": "Asia/Hebron", + "South Africa Standard Time": "Africa/Johannesburg", + "FLE Standard Time": "Europe/Kiev", + "Israel Standard Time": "Asia/Jerusalem", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Sudan Standard Time": "Africa/Khartoum", + "Libya Standard Time": "Africa/Tripoli", + "Namibia Standard Time": "Africa/Windhoek", + "Arabic Standard Time": "Asia/Baghdad", + "Turkey Standard Time": "Europe/Istanbul", + "Arab Standard Time": "Asia/Riyadh", + "Belarus Standard Time": "Europe/Minsk", + "Russian Standard Time": "Europe/Moscow", + "E. Africa Standard Time": "Africa/Nairobi", + "Iran Standard Time": "Asia/Tehran", + "Arabian Standard Time": "Asia/Dubai", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Azerbaijan Standard Time": "Asia/Baku", + "Russia Time Zone 3": "Europe/Samara", + "Mauritius Standard Time": "Indian/Mauritius", + "Saratov Standard Time": "Europe/Saratov", + "Georgian Standard Time": "Asia/Tbilisi", + "Caucasus Standard Time": "Asia/Yerevan", + "Afghanistan Standard Time": "Asia/Kabul", + "West Asia Standard Time": "Asia/Tashkent", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "Pakistan Standard Time": "Asia/Karachi", + "India Standard Time": "Asia/Kolkata", + "Sri Lanka Standard Time": "Asia/Colombo", + "Nepal Standard Time": "Asia/Kathmandu", + "Central Asia Standard Time": "Asia/Almaty", + "Bangladesh Standard Time": "Asia/Dhaka", + "Omsk Standard Time": "Asia/Omsk", + "Myanmar Standard Time": "Asia/Yangon", + "SE Asia Standard Time": "Asia/Bangkok", + "Altai Standard Time": "Asia/Barnaul", + "W. Mongolia Standard Time": "Asia/Hovd", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Tomsk Standard Time": "Asia/Tomsk", + "China Standard Time": "Asia/Shanghai", + "North Asia East Standard Time": "Asia/Irkutsk", + "Singapore Standard Time": "Asia/Singapore", + "W. Australia Standard Time": "Australia/Perth", + "Taipei Standard Time": "Asia/Taipei", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "North Korea Standard Time": "Asia/Pyongyang", + "Aus Central W. Standard Time": "Australia/Eucla", + "Transbaikal Standard Time": "Asia/Chita", + "Tokyo Standard Time": "Asia/Tokyo", + "Korea Standard Time": "Asia/Seoul", + "Yakutsk Standard Time": "Asia/Yakutsk", + "Cen. Australia Standard Time": "Australia/Adelaide", + "AUS Central Standard Time": "Australia/Darwin", + "E. Australia Standard Time": "Australia/Brisbane", + "AUS Eastern Standard Time": "Australia/Sydney", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Tasmania Standard Time": "Australia/Hobart", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Bougainville Standard Time": "Pacific/Bougainville", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Magadan Standard Time": "Asia/Magadan", + "Norfolk Standard Time": "Pacific/Norfolk", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Russia Time Zone 11": "Asia/Kamchatka", + "New Zealand Standard Time": "Pacific/Auckland", + "UTC+12": "Etc/GMT-12", + "Fiji Standard Time": "Pacific/Fiji", + "Kamchatka Standard Time": "Asia/Kamchatka", + "Chatham Islands Standard Time": "Pacific/Chatham", + "UTC+13": "Etc/GMT-13", + "Tonga Standard Time": "Pacific/Tongatapu", + "Samoa Standard Time": "Pacific/Apia", + "Line Islands Standard Time": "Pacific/Kiritimati", +} - val, ok := conversionMap[wtzid] +func ConvertTimeZoneWindowsToLinux(wtzid string) (*string, error) { + val, ok := WindowsTimezones[wtzid] if !ok { return nil, errors.New("invalid windows timezone id") } diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go index 3c3dc4ad10..29c69a1e4c 100644 --- a/runtime/modules/icalendar/parser/parser.go +++ b/runtime/modules/icalendar/parser/parser.go @@ -92,7 +92,7 @@ func (cal *Calendar) Parse() error { } if cal.buffer.IsRecurring { - rInstances = append(rInstances, cal.ExpandRecurringEvent(cal.buffer)...) + rInstances = append(rInstances, cal.ExpandRecurringEvent(cal.buffer, cal)...) } else { if cal.buffer.End == nil || cal.buffer.Start == nil { continue @@ -305,12 +305,14 @@ func (cal *Calendar) parseEvent(l *Line) error { * Reference: https://icalendar.org/iCalendar-RFC-5545/3-8-5-1-exception-date-times.html * Several parameters are allowed. We should pass parameters we have */ - // @TODO NEEDS to be fixed for multiple ex dates - d, err := members.ParseTime(l.Value, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) - if err == nil { + rawDates := strings.Split(l.Value, ",") + for _, date := range rawDates { + d, err := members.ParseTime(date, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) + if err != nil { + return fmt.Errorf("could not parse date: %s", date) + } cal.buffer.ExcludeDates = append(cal.buffer.ExcludeDates, d) } - case "SEQUENCE": cal.buffer.Sequence, _ = strconv.Atoi(l.Value) case "LOCATION": diff --git a/runtime/modules/icalendar/parser/resolver.go b/runtime/modules/icalendar/parser/resolver.go index 2e67f502d3..413ec79eda 100644 --- a/runtime/modules/icalendar/parser/resolver.go +++ b/runtime/modules/icalendar/parser/resolver.go @@ -2,6 +2,7 @@ package parser import ( "fmt" + "strings" "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members" "time" ) @@ -51,6 +52,25 @@ func resolveDate(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { return d, nil, nil } +func resolveExDate(cal *Calendar, l *Line) ([]*time.Time, []*time.Time, error) { + rawDates := strings.Split(l.Value, ",") + if len(rawDates) < 1 { + return nil, nil, fmt.Errorf("invalid date format: %s", l.Value) + } + + var parsedDates []*time.Time + for _, date := range rawDates { + d, err := members.ParseTime(date, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) + if err != nil { + return nil, nil, fmt.Errorf("could not parse: %s", err) + } + + parsedDates = append(parsedDates, d) + } + + return parsedDates, parsedDates, nil +} + func resolveDateEnd(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { d, err := members.ParseTime(l.Value, l.Params, members.TimeEnd, false, cal.AllDayEventsTZ) if err != nil { diff --git a/runtime/modules/icalendar/parser/rrule.go b/runtime/modules/icalendar/parser/rrule.go index d83669e52b..fbae561b8b 100644 --- a/runtime/modules/icalendar/parser/rrule.go +++ b/runtime/modules/icalendar/parser/rrule.go @@ -5,9 +5,9 @@ import ( "time" ) -// @TODO: Remove cancelled events; event can be canelled at the top level or have individual recurrences cancelled +// @TODO: Remove cancelled events; event can be cancelled at the top level or have individual recurrences cancelled -func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { +func (cal *Calendar) ExpandRecurringEvent(buf *Event, calendar *Calendar) []Event { rule, err := rrule.StrToRRule(buf.RawRecurrenceRule) if err != nil || buf.Status == "CANCELLED" { return []Event{} From 3130b597de1f7d91cfa0a1723a79090ec8636c3d Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 14:06:12 -0700 Subject: [PATCH 06/12] feat: provide support for multiple ex dates --- runtime/modules/icalendar/parser/parser.go | 2 +- runtime/modules/icalendar/parser/rrule.go | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go index 29c69a1e4c..e106407fab 100644 --- a/runtime/modules/icalendar/parser/parser.go +++ b/runtime/modules/icalendar/parser/parser.go @@ -92,7 +92,7 @@ func (cal *Calendar) Parse() error { } if cal.buffer.IsRecurring { - rInstances = append(rInstances, cal.ExpandRecurringEvent(cal.buffer, cal)...) + rInstances = append(rInstances, cal.ExpandRecurringEvent(cal.buffer)...) } else { if cal.buffer.End == nil || cal.buffer.Start == nil { continue diff --git a/runtime/modules/icalendar/parser/rrule.go b/runtime/modules/icalendar/parser/rrule.go index fbae561b8b..9cc68af1c8 100644 --- a/runtime/modules/icalendar/parser/rrule.go +++ b/runtime/modules/icalendar/parser/rrule.go @@ -7,7 +7,7 @@ import ( // @TODO: Remove cancelled events; event can be cancelled at the top level or have individual recurrences cancelled -func (cal *Calendar) ExpandRecurringEvent(buf *Event, calendar *Calendar) []Event { +func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { rule, err := rrule.StrToRRule(buf.RawRecurrenceRule) if err != nil || buf.Status == "CANCELLED" { return []Event{} @@ -15,10 +15,19 @@ func (cal *Calendar) ExpandRecurringEvent(buf *Event, calendar *Calendar) []Even allRecurrences := rule.All() + var excludedDateTime map[string]*time.Time + for _, t := range buf.ExcludeDates { + str := t.Format(time.RFC3339) + excludedDateTime[str] = t + } + var expandedEvents []Event for _, rec := range allRecurrences { - e := *buf + if _, ok := excludedDateTime[rec.Format(time.RFC3339)]; ok { + continue + } + e := *buf newEnd := time.Date(rec.Year(), rec.Month(), rec.Day(), buf.End.Hour(), buf.End.Minute(), rec.Second(), buf.End.Nanosecond(), time.UTC) e.Start = &rec From 67a2ce27faf75fbff09ef57a4a713715c429d41b Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 15:08:36 -0700 Subject: [PATCH 07/12] feat: add this thing --- runtime/modules/icalendar/icalendar.go | 34 ++++---------------- runtime/modules/icalendar/parser/parser.go | 16 +++++++++ runtime/modules/icalendar/parser/resolver.go | 20 ------------ runtime/modules/icalendar/parser/rrule.go | 2 -- runtime/modules/icalendar/parser/types.go | 8 +++++ 5 files changed, 30 insertions(+), 50 deletions(-) diff --git a/runtime/modules/icalendar/icalendar.go b/runtime/modules/icalendar/icalendar.go index e60d519f35..238a66e3be 100644 --- a/runtime/modules/icalendar/icalendar.go +++ b/runtime/modules/icalendar/icalendar.go @@ -2,7 +2,6 @@ package icalendar import ( "fmt" - "net/url" "strings" "sync" "tidbyt.dev/pixlet/runtime/modules/icalendar/parser" @@ -31,7 +30,7 @@ func LoadModule() (starlark.StringDict, error) { ModuleName: &starlarkstruct.Module{ Name: ModuleName, Members: starlark.StringDict{ - "findNextEvent": starlark.NewBuiltin("findNextEvent", findNextEvent), + "parseCalendar": starlark.NewBuiltin("findNextEvent", parseCalendar), }, }, } @@ -60,6 +59,7 @@ func parseCalendar(thread *starlark.Thread, _ *starlark.Builtin, args starlark.T } events := make([]starlark.Value, 0, len(calendar.Events)) + for _, event := range calendar.Events { dict := starlark.NewDict(25) @@ -93,16 +93,16 @@ func parseCalendar(thread *starlark.Thread, _ *starlark.Builtin, args starlark.T if err := dict.SetKey(starlark.String("duration_in_seconds"), starlark.Float(event.Duration.Seconds())); err != nil { return nil, fmt.Errorf("setting duration: %s", err) } - if err := dict.SetKey(starlark.String("created_at"), starlark.String(event.Created.Format(time.RFC3339))); err != nil { + if err := dict.SetKey(starlark.String("url"), starlark.String(event.Url)); err != nil { return nil, fmt.Errorf("setting end: %s", err) } - if err := dict.SetKey(starlark.String("updated_at"), starlark.String(event.LastModified.Format(time.RFC3339))); err != nil { + if err := dict.SetKey(starlark.String("sequence"), starlark.MakeInt(event.Sequence)); err != nil { return nil, fmt.Errorf("setting end: %s", err) } - if err := dict.SetKey(starlark.String("url"), starlark.String(event.Url)); err != nil { + if err := dict.SetKey(starlark.String("created_at"), starlark.String(event.Created.Format(time.RFC3339))); err != nil { return nil, fmt.Errorf("setting end: %s", err) } - if err := dict.SetKey(starlark.String("sequence"), starlark.MakeInt(event.Sequence)); err != nil { + if err := dict.SetKey(starlark.String("updated_at"), starlark.String(event.LastModified.Format(time.RFC3339))); err != nil { return nil, fmt.Errorf("setting end: %s", err) } @@ -110,25 +110,3 @@ func parseCalendar(thread *starlark.Thread, _ *starlark.Builtin, args starlark.T } return starlark.NewList(events), nil } - -func findNextEvent(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - return starlark.False, nil -} - -func urlEncode(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var ( - starUrl starlark.String - ) - - if err := starlark.UnpackArgs( - "url_encode", - args, kwargs, - "str", &starUrl, - ); err != nil { - return nil, fmt.Errorf("unpacking arguments for bytes: %s", err) - } - - escapedUrl := url.QueryEscape(starUrl.GoString()) - - return starlark.String(escapedUrl), nil -} diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go index e106407fab..d24b25b6b3 100644 --- a/runtime/modules/icalendar/parser/parser.go +++ b/runtime/modules/icalendar/parser/parser.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "sort" "strconv" "strings" "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members" @@ -152,6 +153,10 @@ func (cal *Calendar) Parse() error { } } + sort.Slice(cal.Events, func(i, j int) bool { + return cal.Events[i].Start.Before(*cal.Events[j].Start) + }) + return nil } @@ -387,6 +392,17 @@ func (cal *Calendar) parseEvent(l *Line) error { } + startTime := cal.buffer.Start.Second() + endTime := cal.buffer.End.Second() + now := time.Now().Second() + + cal.buffer.MetaData.InProgress = now >= startTime + cal.buffer.MetaData.IsThisWeek = now < startTime+7*24*60*60 + cal.buffer.MetaData.IsToday = time.Unix(int64(now), 0).Day() == time.Unix(int64(startTime), 0).Day() + cal.buffer.MetaData.IsTomorrow = time.Unix(int64(now), 0).Day() == time.Unix(int64(startTime), 0).Day()-1 + cal.buffer.MetaData.MinutesUntilStart = int(startTime-now) / 60 + cal.buffer.MetaData.MinutesUntilEnd = int(endTime-now) / 60 + return nil } diff --git a/runtime/modules/icalendar/parser/resolver.go b/runtime/modules/icalendar/parser/resolver.go index 413ec79eda..2e67f502d3 100644 --- a/runtime/modules/icalendar/parser/resolver.go +++ b/runtime/modules/icalendar/parser/resolver.go @@ -2,7 +2,6 @@ package parser import ( "fmt" - "strings" "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members" "time" ) @@ -52,25 +51,6 @@ func resolveDate(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { return d, nil, nil } -func resolveExDate(cal *Calendar, l *Line) ([]*time.Time, []*time.Time, error) { - rawDates := strings.Split(l.Value, ",") - if len(rawDates) < 1 { - return nil, nil, fmt.Errorf("invalid date format: %s", l.Value) - } - - var parsedDates []*time.Time - for _, date := range rawDates { - d, err := members.ParseTime(date, l.Params, members.TimeStart, false, cal.AllDayEventsTZ) - if err != nil { - return nil, nil, fmt.Errorf("could not parse: %s", err) - } - - parsedDates = append(parsedDates, d) - } - - return parsedDates, parsedDates, nil -} - func resolveDateEnd(cal *Calendar, l *Line) (*time.Time, *time.Time, error) { d, err := members.ParseTime(l.Value, l.Params, members.TimeEnd, false, cal.AllDayEventsTZ) if err != nil { diff --git a/runtime/modules/icalendar/parser/rrule.go b/runtime/modules/icalendar/parser/rrule.go index 9cc68af1c8..1da1ce2e19 100644 --- a/runtime/modules/icalendar/parser/rrule.go +++ b/runtime/modules/icalendar/parser/rrule.go @@ -5,8 +5,6 @@ import ( "time" ) -// @TODO: Remove cancelled events; event can be cancelled at the top level or have individual recurrences cancelled - func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { rule, err := rrule.StrToRRule(buf.RawRecurrenceRule) if err != nil || buf.Status == "CANCELLED" { diff --git a/runtime/modules/icalendar/parser/types.go b/runtime/modules/icalendar/parser/types.go index e30b3e3506..4e6ad3d3c0 100644 --- a/runtime/modules/icalendar/parser/types.go +++ b/runtime/modules/icalendar/parser/types.go @@ -136,6 +136,14 @@ type Event struct { Valid bool Comment string Class string + MetaData struct { + InProgress bool + IsThisWeek bool + IsToday bool + IsTomorrow bool + MinutesUntilStart int + MinutesUntilEnd int + } } type Organizer struct { From 99354c57d99fc71906d040ff538fe870abd69c58 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 15:10:58 -0700 Subject: [PATCH 08/12] feat: work in progres --- runtime/modules/icalendar/icalendar_helper.go | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 runtime/modules/icalendar/icalendar_helper.go diff --git a/runtime/modules/icalendar/icalendar_helper.go b/runtime/modules/icalendar/icalendar_helper.go new file mode 100644 index 0000000000..0949e33c08 --- /dev/null +++ b/runtime/modules/icalendar/icalendar_helper.go @@ -0,0 +1,33 @@ +package icalendar + +import ( + "bufio" + "net/http" +) + +type ICalendar struct { + url string + data *bufio.Scanner +} + +func NewICalendar(url string) *ICalendar { + return &ICalendar{ + url: url, + data: nil, + } +} + +func (c *ICalendar) GetCalendar() error { + data, err := http.Get(c.url) + if err != nil { + return err + } + + c.data = bufio.NewScanner(data.Body) + + return err +} + +func (c *ICalendar) ParseCalendar() error { + return nil +} From 871b79a699ee76435e656f723f2728568df2c8a8 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 15:51:46 -0700 Subject: [PATCH 09/12] feat: add to readme --- docs/modules.md | 40 +++++++++++ runtime/applet.go | 4 ++ runtime/modules/icalendar/icalendar.go | 4 +- runtime/modules/icalendar/icalendar_helper.go | 33 ---------- runtime/modules/icalendar/icalendar_test.go | 66 +++++++++++++++++++ 5 files changed, 112 insertions(+), 35 deletions(-) delete mode 100644 runtime/modules/icalendar/icalendar_helper.go create mode 100644 runtime/modules/icalendar/icalendar_test.go diff --git a/docs/modules.md b/docs/modules.md index 9aad04657d..62415e2c9e 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -278,3 +278,43 @@ def main(config): ), ) ``` + + +## Pixlet module: iCalendar + +The `iCalendar` module parses .ics based calendars into individual events. This is module can be used to parse published +calendars from major providers such as Apple, Microsoft, and Google instead of using authentication (OAuth) that's likely +to get denied from office IT security departments. The iCalendar specification can be found [here](https://icalendar.org/RFC-Specifications/iCalendar-RFC-5545/). + +| Function | Description | +| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `parse(rawString)` | Takes a raw iCalendar string and parses into a list of event dictionaries with event data. This function automatically expands recurring events and returns the list sorted by closest start date to furthest start date. | + +Example: + +```starlark +load("icalendar.star", "icalendar") +load(http.star", "http") + +def main(config): + icalendar_url = "http://icalendar.org/your/hosted/ics/file" + timeout = 60 + res = http.get(url = url, ttl_seconds = timeout) + if res.status_code != 200: + fail("request to %s failed with status code: %d - %s" % (url, res.status_code, res.body())) + + events = icalendar.parse(res.body()) + + most_recent_event = events[0] + print(most_recent_event['summary']) + print(most_recent_event['description']) + print(most_recent_event['location']) + print(most_recent_event['start']) // RFC3339 + print(most_recent_event['end']) // RFC3339 + print(most_recent_event['metaData']['minutesUntilStart']) + print(most_recent_event['metaData']['minutesUntilEnd']) + + print("Is Cancelled:", most_recent_event['status'] == 'CANCELLED') + print("Is Today:", most_recent_event['metaData']['isToday']) + print("Is Tomorrow: ", most_recent_event['metaData']['isTomorrow']) +``` \ No newline at end of file diff --git a/runtime/applet.go b/runtime/applet.go index f7d9247656..baf63d792c 100644 --- a/runtime/applet.go +++ b/runtime/applet.go @@ -11,6 +11,7 @@ import ( "strings" "testing" "testing/fstest" + "tidbyt.dev/pixlet/runtime/modules/icalendar" starlibbsoup "github.com/qri-io/starlib/bsoup" starlibgzip "github.com/qri-io/starlib/compress/gzip" @@ -575,6 +576,9 @@ func (a *Applet) loadModule(thread *starlark.Thread, module string) (starlark.St case "humanize.star": return humanize.LoadModule() + case "icalendar.star": + return icalendar.LoadModule() + case "math.star": return starlark.StringDict{ starlibmath.Module.Name: starlibmath.Module, diff --git a/runtime/modules/icalendar/icalendar.go b/runtime/modules/icalendar/icalendar.go index 238a66e3be..cabd6bf2ba 100644 --- a/runtime/modules/icalendar/icalendar.go +++ b/runtime/modules/icalendar/icalendar.go @@ -30,7 +30,7 @@ func LoadModule() (starlark.StringDict, error) { ModuleName: &starlarkstruct.Module{ Name: ModuleName, Members: starlark.StringDict{ - "parseCalendar": starlark.NewBuiltin("findNextEvent", parseCalendar), + "parse": starlark.NewBuiltin("parse", parse), }, }, } @@ -42,7 +42,7 @@ func LoadModule() (starlark.StringDict, error) { /* * This function returns a list of events with the events metadata */ -func parseCalendar(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { +func parse(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var ( rawCalendar starlark.String ) diff --git a/runtime/modules/icalendar/icalendar_helper.go b/runtime/modules/icalendar/icalendar_helper.go deleted file mode 100644 index 0949e33c08..0000000000 --- a/runtime/modules/icalendar/icalendar_helper.go +++ /dev/null @@ -1,33 +0,0 @@ -package icalendar - -import ( - "bufio" - "net/http" -) - -type ICalendar struct { - url string - data *bufio.Scanner -} - -func NewICalendar(url string) *ICalendar { - return &ICalendar{ - url: url, - data: nil, - } -} - -func (c *ICalendar) GetCalendar() error { - data, err := http.Get(c.url) - if err != nil { - return err - } - - c.data = bufio.NewScanner(data.Body) - - return err -} - -func (c *ICalendar) ParseCalendar() error { - return nil -} diff --git a/runtime/modules/icalendar/icalendar_test.go b/runtime/modules/icalendar/icalendar_test.go new file mode 100644 index 0000000000..13a39b1a76 --- /dev/null +++ b/runtime/modules/icalendar/icalendar_test.go @@ -0,0 +1,66 @@ +package icalendar_test + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "tidbyt.dev/pixlet/runtime" +) + +package random_test + +import ( +"context" +"testing" + +"github.com/stretchr/testify/assert" +"github.com/stretchr/testify/require" +"tidbyt.dev/pixlet/runtime" +) + +var randomSrc = ` +load("random.star", "random") + +min = 100 +max = 120 + +def test_number(): + for x in range(0, 300): + num = random.number(min, max) + if num < min: + fail("random number less than min") + if num > max: + fail("random number greater than max") + +def test_seed(): + random.seed(4711) + sequence = [random.number(0, 1 << 20) for _ in range(500)] + + random.seed(4711) # same seed + for i in range(len(sequence)): + if sequence[i] != random.number(0, 1 << 20): + fail("sequence mismatch despite identical seed") + + random.seed(4712) # different seed + different = 0 + for i in range(len(sequence)): + if sequence[i] != random.number(0, 1 << 20): + different += 1 + if not different: + fail("sequences identical despite different seeds") + +test_number() +test_seed() + +def main(): + return [] +` + +func TestRandom(t *testing.T) { + app, err := runtime.NewApplet("random_test.star", []byte(randomSrc)) + require.NoError(t, err) + + screens, err := app.Run(context.Background()) + require.NoError(t, err) + assert.NotNil(t, screens) +} From c27ecd8313c17312dba5d8b7231b2ab7440696a6 Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 15:59:36 -0700 Subject: [PATCH 10/12] feat: add test --- runtime/modules/icalendar/icalendar_test.go | 78 ++++++++++----------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/runtime/modules/icalendar/icalendar_test.go b/runtime/modules/icalendar/icalendar_test.go index 13a39b1a76..dea1da381c 100644 --- a/runtime/modules/icalendar/icalendar_test.go +++ b/runtime/modules/icalendar/icalendar_test.go @@ -7,57 +7,53 @@ import ( "tidbyt.dev/pixlet/runtime" ) -package random_test - import ( -"context" -"testing" - -"github.com/stretchr/testify/assert" -"github.com/stretchr/testify/require" -"tidbyt.dev/pixlet/runtime" + "context" ) var randomSrc = ` -load("random.star", "random") - -min = 100 -max = 120 - -def test_number(): - for x in range(0, 300): - num = random.number(min, max) - if num < min: - fail("random number less than min") - if num > max: - fail("random number greater than max") - -def test_seed(): - random.seed(4711) - sequence = [random.number(0, 1 << 20) for _ in range(500)] - - random.seed(4711) # same seed - for i in range(len(sequence)): - if sequence[i] != random.number(0, 1 << 20): - fail("sequence mismatch despite identical seed") - - random.seed(4712) # different seed - different = 0 - for i in range(len(sequence)): - if sequence[i] != random.number(0, 1 << 20): - different += 1 - if not different: - fail("sequences identical despite different seeds") +load("icalendar.star", "icalendar") +raw_string = """ + BEGIN:VCALENDAR + VERSION:2.0 + CALSCALE:GREGORIAN + BEGIN:VTIMEZONE + TZID:America/Phoenix + LAST-MODIFIED:20231222T233358Z + TZURL:https://www.tzurl.org/zoneinfo-outlook/America/Phoenix + X-LIC-LOCATION:America/Phoenix + BEGIN:STANDARD + TZNAME:MST + TZOFFSETFROM:-0700 + TZOFFSETTO:-0700 + DTSTART:19700101T000000 + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + DTSTAMP:20240817T225510Z + UID:1723935287215-82939@ical.marudot.com + DTSTART;TZID=America/Phoenix:20240801T120000 + RRULE:FREQ=DAILY + DTEND;TZID=America/Phoenix:20240801T150000 + SUMMARY:Test + DESCRIPTION:Test Description + LOCATION:Phoenix + END:VEVENT + END:VCALENDAR +""" + +def test_icalendar(): + events = icalendar.parse(raw_string) + for event in events: + print(event) -test_number() -test_seed() def main(): return [] ` -func TestRandom(t *testing.T) { - app, err := runtime.NewApplet("random_test.star", []byte(randomSrc)) +func TestICalendar(t *testing.T) { + app, err := runtime.NewApplet("icalendar_test.star", []byte(randomSrc)) require.NoError(t, err) screens, err := app.Run(context.Background()) From d26673e2ae0503e64a10c812cf1b0ece72e359ad Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 16:44:14 -0700 Subject: [PATCH 11/12] feat: finish tests --- runtime/modules/icalendar/icalendar_test.go | 63 +++++++++++---------- runtime/modules/icalendar/parser/parser.go | 32 ++++++----- runtime/modules/icalendar/parser/rrule.go | 7 ++- runtime/modules/icalendar/parser/types.go | 4 +- 4 files changed, 56 insertions(+), 50 deletions(-) diff --git a/runtime/modules/icalendar/icalendar_test.go b/runtime/modules/icalendar/icalendar_test.go index dea1da381c..f13a2aa403 100644 --- a/runtime/modules/icalendar/icalendar_test.go +++ b/runtime/modules/icalendar/icalendar_test.go @@ -11,49 +11,50 @@ import ( "context" ) -var randomSrc = ` +var icalendarSrc = ` load("icalendar.star", "icalendar") raw_string = """ - BEGIN:VCALENDAR - VERSION:2.0 - CALSCALE:GREGORIAN - BEGIN:VTIMEZONE - TZID:America/Phoenix - LAST-MODIFIED:20231222T233358Z - TZURL:https://www.tzurl.org/zoneinfo-outlook/America/Phoenix - X-LIC-LOCATION:America/Phoenix - BEGIN:STANDARD - TZNAME:MST - TZOFFSETFROM:-0700 - TZOFFSETTO:-0700 - DTSTART:19700101T000000 - END:STANDARD - END:VTIMEZONE - BEGIN:VEVENT - DTSTAMP:20240817T225510Z - UID:1723935287215-82939@ical.marudot.com - DTSTART;TZID=America/Phoenix:20240801T120000 - RRULE:FREQ=DAILY - DTEND;TZID=America/Phoenix:20240801T150000 - SUMMARY:Test - DESCRIPTION:Test Description - LOCATION:Phoenix - END:VEVENT - END:VCALENDAR +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:America/Phoenix +LAST-MODIFIED:20231222T233358Z +TZURL:https://www.tzurl.org/zoneinfo-outlook/America/Phoenix +X-LIC-LOCATION:America/Phoenix +BEGIN:STANDARD +TZNAME:MST +TZOFFSETFROM:-0700 +TZOFFSETTO:-0700 +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:20240817T225510Z +UID:1723935287215-82939@ical.marudot.com +DTSTART;TZID=America/Phoenix:20240801T120000 +RRULE:FREQ=DAILY +DTEND;TZID=America/Phoenix:20240801T150000 +SUMMARY:Test +DESCRIPTION:Test Description +LOCATION:Phoenix +END:VEVENT +END:VCALENDAR """ def test_icalendar(): events = icalendar.parse(raw_string) - for event in events: - print(event) + return events + def main(): - return [] + return test_icalendar() + ` func TestICalendar(t *testing.T) { - app, err := runtime.NewApplet("icalendar_test.star", []byte(randomSrc)) + app, err := runtime.NewApplet("icalendar_test.star", []byte(icalendarSrc)) require.NoError(t, err) screens, err := app.Run(context.Background()) diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go index d24b25b6b3..810442a20f 100644 --- a/runtime/modules/icalendar/parser/parser.go +++ b/runtime/modules/icalendar/parser/parser.go @@ -44,10 +44,10 @@ func (cal *Calendar) Parse() error { for { l, err, done := cal.parseLine() + if done { + break + } if err != nil { - if done { - break - } continue } @@ -243,14 +243,14 @@ func (cal *Calendar) parseEvent(l *Line) error { } case "DTSTART": if err := resolve(cal, l, &cal.buffer.Start, resolveDate, func(cal *Calendar, out *time.Time) { - cal.buffer.RawStart = &RawDate{Value: l.Value, Params: l.Params} + cal.buffer.RawStart = RawDate{Value: l.Value, Params: l.Params} }); err != nil { return err } case "DTEND": if err := resolve(cal, l, &cal.buffer.End, resolveDateEnd, func(cal *Calendar, out *time.Time) { - cal.buffer.RawEnd = &RawDate{Value: l.Value, Params: l.Params} + cal.buffer.RawEnd = RawDate{Value: l.Value, Params: l.Params} }); err != nil { return err } @@ -392,16 +392,18 @@ func (cal *Calendar) parseEvent(l *Line) error { } - startTime := cal.buffer.Start.Second() - endTime := cal.buffer.End.Second() - now := time.Now().Second() - - cal.buffer.MetaData.InProgress = now >= startTime - cal.buffer.MetaData.IsThisWeek = now < startTime+7*24*60*60 - cal.buffer.MetaData.IsToday = time.Unix(int64(now), 0).Day() == time.Unix(int64(startTime), 0).Day() - cal.buffer.MetaData.IsTomorrow = time.Unix(int64(now), 0).Day() == time.Unix(int64(startTime), 0).Day()-1 - cal.buffer.MetaData.MinutesUntilStart = int(startTime-now) / 60 - cal.buffer.MetaData.MinutesUntilEnd = int(endTime-now) / 60 + if cal.buffer.Start != nil && cal.buffer.End != nil { + startTime := cal.buffer.Start.Second() + endTime := cal.buffer.End.Second() + now := time.Now().Second() + + cal.buffer.MetaData.InProgress = now >= startTime + cal.buffer.MetaData.IsThisWeek = now < startTime+7*24*60*60 + cal.buffer.MetaData.IsToday = time.Unix(int64(now), 0).Day() == time.Unix(int64(startTime), 0).Day() + cal.buffer.MetaData.IsTomorrow = time.Unix(int64(now), 0).Day() == time.Unix(int64(startTime), 0).Day()-1 + cal.buffer.MetaData.MinutesUntilStart = int(startTime-now) / 60 + cal.buffer.MetaData.MinutesUntilEnd = int(endTime-now) / 60 + } return nil } diff --git a/runtime/modules/icalendar/parser/rrule.go b/runtime/modules/icalendar/parser/rrule.go index 1da1ce2e19..efec9fd8fb 100644 --- a/runtime/modules/icalendar/parser/rrule.go +++ b/runtime/modules/icalendar/parser/rrule.go @@ -11,7 +11,10 @@ func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { return []Event{} } - allRecurrences := rule.All() + now := time.Now() + threeMonthsFromNow := now.AddDate(0, 3, 0) + + nextThreeMonthsOfRecurrences := rule.Between(now, threeMonthsFromNow, true) var excludedDateTime map[string]*time.Time for _, t := range buf.ExcludeDates { @@ -20,7 +23,7 @@ func (cal *Calendar) ExpandRecurringEvent(buf *Event) []Event { } var expandedEvents []Event - for _, rec := range allRecurrences { + for _, rec := range nextThreeMonthsOfRecurrences { if _, ok := excludedDateTime[rec.Format(time.RFC3339)]; ok { continue } diff --git a/runtime/modules/icalendar/parser/types.go b/runtime/modules/icalendar/parser/types.go index 4e6ad3d3c0..06e8047452 100644 --- a/runtime/modules/icalendar/parser/types.go +++ b/runtime/modules/icalendar/parser/types.go @@ -113,8 +113,8 @@ type Event struct { Categories []string Start *time.Time End *time.Time - RawStart *RawDate - RawEnd *RawDate + RawStart RawDate + RawEnd RawDate Duration *time.Duration Stamp *time.Time Created *time.Time From 7eb2a70199e4a1a4450fb72f0b7f71a158ac3f7c Mon Sep 17 00:00:00 2001 From: "Kyle C. R. Fahey" Date: Sat, 17 Aug 2024 16:45:37 -0700 Subject: [PATCH 12/12] docs: fix documentation --- docs/modules.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/modules.md b/docs/modules.md index 62415e2c9e..fdaee46efe 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -286,9 +286,9 @@ The `iCalendar` module parses .ics based calendars into individual events. This calendars from major providers such as Apple, Microsoft, and Google instead of using authentication (OAuth) that's likely to get denied from office IT security departments. The iCalendar specification can be found [here](https://icalendar.org/RFC-Specifications/iCalendar-RFC-5545/). -| Function | Description | -| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `parse(rawString)` | Takes a raw iCalendar string and parses into a list of event dictionaries with event data. This function automatically expands recurring events and returns the list sorted by closest start date to furthest start date. | +| Function | Description | +| --- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `parse(rawString)` | Takes a raw iCalendar string and parses into a list of event dictionaries with event data. This function automatically expands recurring events and returns the list sorted by closest start date to furthest start date reaching maximum of 3 months of recurring dates from the current time to avoid overloading memory and infinite loops. | Example: