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 +}