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/docs/modules.md b/docs/modules.md
index 9aad04657d..fdaee46efe 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 reaching maximum of 3 months of recurring dates from the current time to avoid overloading memory and infinite loops. |
+
+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/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/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
new file mode 100644
index 0000000000..cabd6bf2ba
--- /dev/null
+++ b/runtime/modules/icalendar/icalendar.go
@@ -0,0 +1,112 @@
+package icalendar
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+ "tidbyt.dev/pixlet/runtime/modules/icalendar/parser"
+ "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{
+ "parse": starlark.NewBuiltin("parse", parse),
+ },
+ },
+ }
+ })
+
+ return module, nil
+}
+
+/*
+* This function returns a list of events with the events metadata
+ */
+func parse(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("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)
+ }
+ 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)
+ }
+
+ events = append(events, dict)
+ }
+ return starlark.NewList(events), nil
+}
diff --git a/runtime/modules/icalendar/icalendar_test.go b/runtime/modules/icalendar/icalendar_test.go
new file mode 100644
index 0000000000..f13a2aa403
--- /dev/null
+++ b/runtime/modules/icalendar/icalendar_test.go
@@ -0,0 +1,63 @@
+package icalendar_test
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "testing"
+ "tidbyt.dev/pixlet/runtime"
+)
+
+import (
+ "context"
+)
+
+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
+"""
+
+def test_icalendar():
+ events = icalendar.parse(raw_string)
+ return events
+
+
+
+def main():
+ return test_icalendar()
+
+`
+
+func TestICalendar(t *testing.T) {
+ app, err := runtime.NewApplet("icalendar_test.star", []byte(icalendarSrc))
+ require.NoError(t, err)
+
+ screens, err := app.Run(context.Background())
+ require.NoError(t, err)
+ assert.NotNil(t, screens)
+}
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/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/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..a69485d3aa
--- /dev/null
+++ b/runtime/modules/icalendar/parser/members/timezone_conversion.go
@@ -0,0 +1,152 @@
+package members
+
+import "errors"
+
+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",
+}
+
+func ConvertTimeZoneWindowsToLinux(wtzid string) (*string, error) {
+ val, ok := WindowsTimezones[wtzid]
+ if !ok {
+ return nil, errors.New("invalid windows timezone id")
+ }
+
+ return &val, nil
+}
diff --git a/runtime/modules/icalendar/parser/parser.go b/runtime/modules/icalendar/parser/parser.go
new file mode 100644
index 0000000000..810442a20f
--- /dev/null
+++ b/runtime/modules/icalendar/parser/parser.go
@@ -0,0 +1,429 @@
+package parser
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+ "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members"
+ "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,
+ }
+}
+
+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 done {
+ break
+ }
+ if err != nil {
+ 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)
+ }
+ }
+
+ sort.Slice(cal.Events, func(i, j int) bool {
+ return cal.Events[i].Start.Before(*cal.Events[j].Start)
+ })
+
+ 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() (bool, 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 done, 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}
+}
+
+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
+ */
+ 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":
+ 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
+ }
+
+ }
+
+ 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
+}
+
+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
new file mode 100644
index 0000000000..2e67f502d3
--- /dev/null
+++ b/runtime/modules/icalendar/parser/resolver.go
@@ -0,0 +1,80 @@
+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 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 {
+ 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..efec9fd8fb
--- /dev/null
+++ b/runtime/modules/icalendar/parser/rrule.go
@@ -0,0 +1,41 @@
+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 || buf.Status == "CANCELLED" {
+ return []Event{}
+ }
+
+ 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 {
+ str := t.Format(time.RFC3339)
+ excludedDateTime[str] = t
+ }
+
+ var expandedEvents []Event
+ for _, rec := range nextThreeMonthsOfRecurrences {
+ 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
+ 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
new file mode 100644
index 0000000000..06e8047452
--- /dev/null
+++ b/runtime/modules/icalendar/parser/types.go
@@ -0,0 +1,186 @@
+package parser
+
+import (
+ "bufio"
+ "fmt"
+ "strings"
+ "tidbyt.dev/pixlet/runtime/modules/icalendar/parser/members"
+ "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 NewDuplicateAttribute(k, v string) DuplicateAttributeError {
+ return DuplicateAttributeError{Key: k, Value: v}
+}
+
+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
+ Previous *Context
+}
+
+func (ctx *Context) Nest(value int) *Context {
+ return &Context{Value: value, Previous: ctx}
+}
+
+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 string) bool {
+ return strings.TrimSpace(l.Key) == key
+}
+
+func (l *Line) IsValue(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
+ RawRecurrenceRule string
+ ExcludeDates []*time.Time
+ Sequence int
+ CustomAttributes map[string]string
+ Valid bool
+ Comment string
+ Class string
+ MetaData struct {
+ InProgress bool
+ IsThisWeek bool
+ IsToday bool
+ IsTomorrow bool
+ MinutesUntilStart int
+ MinutesUntilEnd int
+ }
+}
+
+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 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
+}