diff --git a/src/cmd/converter/converter.go b/src/cmd/converter/converter.go index 7dd315140..aa878d8ec 100644 --- a/src/cmd/converter/converter.go +++ b/src/cmd/converter/converter.go @@ -7,6 +7,7 @@ import ( "os" "github.com/alcionai/corso/src/internal/converters/eml" + "github.com/alcionai/corso/src/internal/converters/ics" "github.com/alcionai/corso/src/internal/converters/vcf" ) @@ -40,6 +41,11 @@ func main() { if err != nil { log.Fatal(err) } + case "ics": + out, err = ics.FromJSON(context.Background(), body) + if err != nil { + log.Fatal(err) + } default: log.Fatal("Unknown target format", to) } diff --git a/src/go.mod b/src/go.mod index df9a19a8a..3f4ed44d4 100644 --- a/src/go.mod +++ b/src/go.mod @@ -59,6 +59,7 @@ require ( github.com/VividCortex/ewma v1.2.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/andybalholm/brotli v1.0.6 // indirect + github.com/arran4/golang-ical v0.2.3 // indirect github.com/aws/aws-sdk-go v1.48.6 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect diff --git a/src/go.sum b/src/go.sum index 2c2b102f9..68f28610a 100644 --- a/src/go.sum +++ b/src/go.sum @@ -33,6 +33,8 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sx github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/arran4/golang-ical v0.2.3 h1:C4Vj7+BjJBIrAJhHgi6Ku+XUkQVugRq4re5Cqj5QVdE= +github.com/arran4/golang-ical v0.2.3/go.mod h1:RqMuPGmwRRwjkb07hmm+JBqcWa1vF1LvVmPtSZN2OhQ= github.com/aws/aws-sdk-go v1.48.6 h1:hnL/TE3eRigirDLrdRE9AWE1ALZSVLAsC4wK8TGsMqk= github.com/aws/aws-sdk-go v1.48.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-xray-sdk-go v1.8.3 h1:S8GdgVncBRhzbNnNUgTPwhEqhwt2alES/9rLASyhxjU= @@ -57,6 +59,7 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp github.com/cjlapao/common-go v0.0.39 h1:bAAUrj2B9v0kMzbAOhzjSmiyDy+rd56r2sy7oEiQLlA= github.com/cjlapao/common-go v0.0.39/go.mod h1:M3dzazLjTjEtZJbbxoA5ZDiGCiHmpwqW9l4UWaddwOA= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -230,6 +233,7 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= @@ -413,6 +417,7 @@ google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= @@ -425,6 +430,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= diff --git a/src/internal/converters/ics/consts.go b/src/internal/converters/ics/consts.go new file mode 100644 index 000000000..b8b25e3cb --- /dev/null +++ b/src/internal/converters/ics/consts.go @@ -0,0 +1,168 @@ +package ics + +var ( + // Map from Graph API recurrence type to iCal recurrence type + GraphToICalIndex = map[string]int{ + "first": 1, + "second": 2, + "third": 3, + "fourth": 4, + "last": -1, + } + + // Map from Graph API day of week representation to iCal day of week representation + GraphToICalDOW = map[string]string{ + "sunday": "SU", + "monday": "MO", + "tuesday": "TU", + "wednesday": "WE", + "thursday": "TH", + "friday": "FR", + "saturday": "SA", + } +) + +// Map from Window time zone to TZ database time zone +// https://github.com/closeio/sync-engine/blob/1ce0e1ad0104a2ab2479da09b073c86f4feee5f9/inbox/events/timezones.py#L6 +var GraphTimeZoneToTZ = map[string]string{ + "AUS Central Standard Time": "Australia/Darwin", + "AUS Eastern Standard Time": "Australia/Sydney", + "Afghanistan Standard Time": "Asia/Kabul", + "Alaskan Standard Time": "America/Anchorage", + "Aleutian Standard Time": "America/Adak", + "Altai Standard Time": "Asia/Barnaul", + "Arab Standard Time": "Asia/Riyadh", + "Arabian Standard Time": "Asia/Dubai", + "Arabic Standard Time": "Asia/Baghdad", + "Argentina Standard Time": "America/Buenos_Aires", + "Astrakhan Standard Time": "Europe/Astrakhan", + "Atlantic Standard Time": "America/Halifax", + "Aus Central W. Standard Time": "Australia/Eucla", + "Azerbaijan Standard Time": "Asia/Baku", + "Azores Standard Time": "Atlantic/Azores", + "Bahia Standard Time": "America/Bahia", + "Bangladesh Standard Time": "Asia/Dhaka", + "Belarus Standard Time": "Europe/Minsk", + "Bougainville Standard Time": "Pacific/Bougainville", + "Canada Central Standard Time": "America/Regina", + "Cape Verde Standard Time": "Atlantic/Cape_Verde", + "Caucasus Standard Time": "Asia/Yerevan", + "Cen. Australia Standard Time": "Australia/Adelaide", + "Central America Standard Time": "America/Guatemala", + "Central Asia Standard Time": "Asia/Almaty", + "Central Brazilian Standard Time": "America/Cuiaba", + "Central Europe Standard Time": "Europe/Budapest", + "Central European Standard Time": "Europe/Warsaw", + "Central Pacific Standard Time": "Pacific/Guadalcanal", + "Central Standard Time": "America/Chicago", + "Central Standard Time (Mexico)": "America/Mexico_City", + "Chatham Islands Standard Time": "Pacific/Chatham", + "China Standard Time": "Asia/Shanghai", + "Cuba Standard Time": "America/Havana", + "Dateline Standard Time": "Etc/GMT+12", + "E. Africa Standard Time": "Africa/Nairobi", + "E. Australia Standard Time": "Australia/Brisbane", + "E. Europe Standard Time": "Europe/Chisinau", + "E. South America Standard Time": "America/Sao_Paulo", + "Easter Island Standard Time": "Pacific/Easter", + "Eastern Standard Time": "America/New_York", + "Eastern Standard Time (Mexico)": "America/Cancun", + "Egypt Standard Time": "Africa/Cairo", + "Ekaterinburg Standard Time": "Asia/Yekaterinburg", + "FLE Standard Time": "Europe/Kiev", + "Fiji Standard Time": "Pacific/Fiji", + "GMT Standard Time": "Europe/London", + "GTB Standard Time": "Europe/Bucharest", + "Georgian Standard Time": "Asia/Tbilisi", + "Greenland Standard Time": "America/Godthab", + "Greenwich Standard Time": "Atlantic/Reykjavik", + "Haiti Standard Time": "America/Port-au-Prince", + "Hawaiian Standard Time": "Pacific/Honolulu", + "India Standard Time": "Asia/Calcutta", + "Iran Standard Time": "Asia/Tehran", + "Israel Standard Time": "Asia/Jerusalem", + "Jordan Standard Time": "Asia/Amman", + "Kaliningrad Standard Time": "Europe/Kaliningrad", + "Korea Standard Time": "Asia/Seoul", + "Libya Standard Time": "Africa/Tripoli", + "Line Islands Standard Time": "Pacific/Kiritimati", + "Lord Howe Standard Time": "Australia/Lord_Howe", + "Magadan Standard Time": "Asia/Magadan", + "Magallanes Standard Time": "America/Punta_Arenas", + "Marquesas Standard Time": "Pacific/Marquesas", + "Mauritius Standard Time": "Indian/Mauritius", + "Middle East Standard Time": "Asia/Beirut", + "Montevideo Standard Time": "America/Montevideo", + "Morocco Standard Time": "Africa/Casablanca", + "Mountain Standard Time": "America/Denver", + "Mountain Standard Time (Mexico)": "America/Chihuahua", + "Myanmar Standard Time": "Asia/Rangoon", + "N. Central Asia Standard Time": "Asia/Novosibirsk", + "Namibia Standard Time": "Africa/Windhoek", + "Nepal Standard Time": "Asia/Katmandu", + "New Zealand Standard Time": "Pacific/Auckland", + "Newfoundland Standard Time": "America/St_Johns", + "Norfolk Standard Time": "Pacific/Norfolk", + "North Asia East Standard Time": "Asia/Irkutsk", + "North Asia Standard Time": "Asia/Krasnoyarsk", + "North Korea Standard Time": "Asia/Pyongyang", + "Omsk Standard Time": "Asia/Omsk", + "Pacific SA Standard Time": "America/Santiago", + "Pacific Standard Time": "America/Los_Angeles", + "Pacific Standard Time (Mexico)": "America/Tijuana", + "Pakistan Standard Time": "Asia/Karachi", + "Paraguay Standard Time": "America/Asuncion", + "Qyzylorda Standard Time": "Asia/Qyzylorda", + "Romance Standard Time": "Europe/Paris", + "Russia Time Zone 10": "Asia/Srednekolymsk", + "Russia Time Zone 11": "Asia/Kamchatka", + "Russia Time Zone 3": "Europe/Samara", + "Russian Standard Time": "Europe/Moscow", + "SA Eastern Standard Time": "America/Cayenne", + "SA Pacific Standard Time": "America/Bogota", + "SA Western Standard Time": "America/La_Paz", + "SE Asia Standard Time": "Asia/Bangkok", + "Saint Pierre Standard Time": "America/Miquelon", + "Sakhalin Standard Time": "Asia/Sakhalin", + "Samoa Standard Time": "Pacific/Apia", + "Sao Tome Standard Time": "Africa/Sao_Tome", + "Saratov Standard Time": "Europe/Saratov", + "Singapore Standard Time": "Asia/Singapore", + "South Africa Standard Time": "Africa/Johannesburg", + "South Sudan Standard Time": "Africa/Juba", + "Sri Lanka Standard Time": "Asia/Colombo", + "Sudan Standard Time": "Africa/Khartoum", + "Syria Standard Time": "Asia/Damascus", + "Taipei Standard Time": "Asia/Taipei", + "Tasmania Standard Time": "Australia/Hobart", + "Tocantins Standard Time": "America/Araguaina", + "Tokyo Standard Time": "Asia/Tokyo", + "Tomsk Standard Time": "Asia/Tomsk", + "Tonga Standard Time": "Pacific/Tongatapu", + "Transbaikal Standard Time": "Asia/Chita", + "Turkey Standard Time": "Europe/Istanbul", + "Turks And Caicos Standard Time": "America/Grand_Turk", + "US Eastern Standard Time": "America/Indianapolis", + "US Mountain Standard Time": "America/Phoenix", + "UTC": "Etc/UTC", + "UTC+12": "Etc/GMT-12", + "UTC+13": "Etc/GMT-13", + "UTC-02": "Etc/GMT+2", + "UTC-08": "Etc/GMT+8", + "UTC-09": "Etc/GMT+9", + "UTC-11": "Etc/GMT+11", + "Ulaanbaatar Standard Time": "Asia/Ulaanbaatar", + "Venezuela Standard Time": "America/Caracas", + "Vladivostok Standard Time": "Asia/Vladivostok", + "Volgograd Standard Time": "Europe/Volgograd", + "W. Australia Standard Time": "Australia/Perth", + "W. Central Africa Standard Time": "Africa/Lagos", + "W. Europe Standard Time": "Europe/Berlin", + "W. Mongolia Standard Time": "Asia/Hovd", + "West Asia Standard Time": "Asia/Tashkent", + "West Bank Standard Time": "Asia/Hebron", + "West Pacific Standard Time": "Pacific/Port_Moresby", + "Yakutsk Standard Time": "Asia/Yakutsk", + "Yukon Standard Time": "America/Whitehorse", + "tzone://Microsoft/Utc": "Etc/UTC", +} diff --git a/src/internal/converters/ics/ics.go b/src/internal/converters/ics/ics.go new file mode 100644 index 000000000..10f0c1d20 --- /dev/null +++ b/src/internal/converters/ics/ics.go @@ -0,0 +1,447 @@ +package ics + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/alcionai/clues" + ics "github.com/arran4/golang-ical" + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/common/str" + "github.com/alcionai/corso/src/pkg/dttm" + "github.com/alcionai/corso/src/pkg/services/m365/api" +) + +// This package is used to convert json response from graph to ics +// Ref: https://icalendar.org/ +// Ref: https://www.rfc-editor.org/rfc/rfc5545 +// Ref: https://learn.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-1.0 + +// TODO: Items not handled +// locations (different from location) +// exceptions and modifications + +// Field in the backed up data that we cannot handle +// allowNewTimeProposals, hideAttendees, importance, isOnlineMeeting, +// isOrganizer, isReminderOn, onlineMeeting, onlineMeetingProvider, +// onlineMeetingUrl, originalEndTimeZone, originalStart, +// originalStartTimeZone, reminderMinutesBeforeStart, responseRequested, +// responseStatus, sensitivity + +func keyValues(key, value string) *ics.KeyValues { + return &ics.KeyValues{ + Key: key, + Value: []string{value}, + } +} + +func getLocationString(location models.Locationable) string { + if location == nil { + return "" + } + + dn := ptr.Val(location.GetDisplayName()) + segments := []string{dn} + + // TODO: Handle different location types + addr := location.GetAddress() + if addr != nil { + street := ptr.Val(addr.GetStreet()) + city := ptr.Val(addr.GetCity()) + state := ptr.Val(addr.GetState()) + country := ptr.Val(addr.GetCountryOrRegion()) + postal := ptr.Val(addr.GetPostalCode()) + + segments = append(segments, street, city, state, country, postal) + } + + nonEmpty := []string{} + + for _, seg := range segments { + if len(seg) > 0 { + nonEmpty = append(nonEmpty, seg) + } + } + + return strings.Join(nonEmpty, ", ") +} + +func getUTCTime(ts, tz string) (time.Time, error) { + // Timezone is always converted to UTC. This is the easiest way to + // ensure we have the correct time as the .ics file expects the same + // timezone everywhere according to the spec. + it, err := dttm.ParseTime(ts) + if err != nil { + return time.Now(), clues.Wrap(err, "parsing time") + } + + timezone, ok := GraphTimeZoneToTZ[tz] + if !ok { + return it, clues.New("unknown timezone") + } + + loc, err := time.LoadLocation(timezone) + if err != nil { + return time.Now(), clues.Wrap(err, "loading timezone") + } + + // embed timezone + locTime := time.Date(it.Year(), it.Month(), it.Day(), it.Hour(), it.Minute(), it.Second(), 0, loc) + + return locTime.UTC(), nil +} + +// https://www.rfc-editor.org/rfc/rfc5545#section-3.8.5.3 +// https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10 +// https://learn.microsoft.com/en-us/graph/api/resources/patternedrecurrence?view=graph-rest-1.0 +// Ref: https://github.com/closeio/sync-engine/pull/381/files +func getRecurrencePattern( + ctx context.Context, + recurrence models.PatternedRecurrenceable, +) (string, error) { + recurComponents := []string{} + pat := recurrence.GetPattern() + + freq := pat.GetTypeEscaped() + if freq != nil { + switch *freq { + case models.DAILY_RECURRENCEPATTERNTYPE: + recurComponents = append(recurComponents, "FREQ=DAILY") + case models.WEEKLY_RECURRENCEPATTERNTYPE: + recurComponents = append(recurComponents, "FREQ=WEEKLY") + case models.ABSOLUTEMONTHLY_RECURRENCEPATTERNTYPE, models.RELATIVEMONTHLY_RECURRENCEPATTERNTYPE: + recurComponents = append(recurComponents, "FREQ=MONTHLY") + case models.ABSOLUTEYEARLY_RECURRENCEPATTERNTYPE, models.RELATIVEYEARLY_RECURRENCEPATTERNTYPE: + recurComponents = append(recurComponents, "FREQ=YEARLY") + } + } + + interval := pat.GetInterval() + if interval != nil { + recurComponents = append(recurComponents, "INTERVAL="+fmt.Sprint(ptr.Val(interval))) + } + + month := ptr.Val(pat.GetMonth()) + if month > 0 { + recurComponents = append(recurComponents, "BYMONTH="+fmt.Sprint(month)) + } + + // This is required if absoluteMonthly or absoluteYearly + day := ptr.Val(pat.GetDayOfMonth()) + if day > 0 { + recurComponents = append(recurComponents, "BYMONTHDAY="+fmt.Sprint(day)) + } + + dow := pat.GetDaysOfWeek() + if dow != nil { + dowComponents := []string{} + + for _, day := range dow { + icalday, ok := GraphToICalDOW[day.String()] + if !ok { + return "", clues.NewWC(ctx, "unknown day of week").With("day", day.String()) + } + + dowComponents = append(dowComponents, icalday) + } + + index := pat.GetIndex() + prefix := "" + + if index != nil && + (ptr.Val(freq) == models.RELATIVEMONTHLY_RECURRENCEPATTERNTYPE || + ptr.Val(freq) == models.RELATIVEYEARLY_RECURRENCEPATTERNTYPE) { + prefix = fmt.Sprint(GraphToICalIndex[index.String()]) + } + + recurComponents = append(recurComponents, "BYDAY="+prefix+strings.Join(dowComponents, ",")) + } + + rrange := recurrence.GetRangeEscaped() + if rrange != nil { + switch ptr.Val(rrange.GetTypeEscaped()) { + case models.ENDDATE_RECURRENCERANGETYPE: + end := rrange.GetEndDate() + if end != nil { + // NOTE: We convert just a date into date+time in a + // different timezone which will cause it to not be just + // a date anymore. + endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone())) + if err != nil { + return "", clues.Wrap(err, "parsing end time") + } + + recurComponents = append(recurComponents, "UNTIL="+endTime.Format("20060102T150405Z")) + } + case models.NOEND_RECURRENCERANGETYPE: + // Nothing to do + case models.NUMBERED_RECURRENCERANGETYPE: + count := ptr.Val(rrange.GetNumberOfOccurrences()) + if count > 0 { + recurComponents = append(recurComponents, "COUNT="+fmt.Sprint(count)) + } + } + } + + return strings.Join(recurComponents, ";"), nil +} + +func FromJSON(ctx context.Context, body []byte) (string, error) { + data, err := api.BytesToEventable(body) + if err != nil { + return "", clues.Wrap(err, "converting to eventable") + } + + cal := ics.NewCalendar() + cal.SetProductId("-//Alcion//Corso") // Does this have to be customizable? + + id := data.GetId() // XXX: iCalUId? + event := cal.AddEvent(ptr.Val(id)) + + created := data.GetCreatedDateTime() + if created != nil { + event.SetCreatedTime(ptr.Val(created)) + } + + modified := data.GetLastModifiedDateTime() + if modified != nil { + event.SetModifiedAt(ptr.Val(modified)) + } + + allDay := ptr.Val(data.GetIsAllDay()) + + startString := data.GetStart().GetDateTime() + timeZone := data.GetStart().GetTimeZone() + + if startString != nil { + start, err := getUTCTime(ptr.Val(startString), ptr.Val(timeZone)) + if err != nil { + return "", clues.Wrap(err, "parsing start time") + } + + if allDay { + event.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate))) + } else { + event.SetStartAt(start) + } + } + + endString := data.GetEnd().GetDateTime() + timeZone = data.GetEnd().GetTimeZone() + + if endString != nil { + end, err := getUTCTime(ptr.Val(endString), ptr.Val(timeZone)) + if err != nil { + return "", clues.Wrap(err, "parsing end time") + } + + if allDay { + event.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate))) + } else { + event.SetEndAt(end) + } + } + + recurrence := data.GetRecurrence() + if recurrence != nil { + pattern, err := getRecurrencePattern(ctx, recurrence) + if err != nil { + return "", clues.WrapWC(ctx, err, "generating RRULE") + } + + event.AddRrule(pattern) + } + + cancelled := data.GetIsCancelled() + if cancelled != nil { + event.SetStatus(ics.ObjectStatusCancelled) + } + + draft := data.GetIsDraft() + if draft != nil { + event.SetStatus(ics.ObjectStatusDraft) + } + + summary := data.GetSubject() + if summary != nil { + event.SetSummary(ptr.Val(summary)) + } + + // TODO: Emojies currently don't seem to be read properly by Outlook + bodyPreview := ptr.Val(data.GetBodyPreview()) + + if data.GetBody() != nil { + description := ptr.Val(data.GetBody().GetContent()) + contentType := data.GetBody().GetContentType().String() + + if len(description) > 0 && contentType == "text" { + event.SetDescription(description) + } else { + // https://stackoverflow.com/a/859475 + event.SetDescription(bodyPreview) + + if contentType == "html" { + desc := strings.ReplaceAll(description, "\r\n", "") + desc = strings.ReplaceAll(desc, "\n", "") + event.AddProperty("X-ALT-DESC", desc, ics.WithFmtType("text/html")) + } + } + } + + showAs := ptr.Val(data.GetShowAs()).String() + if len(showAs) > 0 && showAs != "unknown" { + var status ics.FreeBusyTimeType + + switch showAs { + case "free": + status = ics.FreeBusyTimeTypeFree + case "tentative": + status = ics.FreeBusyTimeTypeBusyTentative + case "busy": + status = ics.FreeBusyTimeTypeBusy + case "oof", "workingElsewhere": // this is just best effort conversion + status = ics.FreeBusyTimeTypeBusyUnavailable + } + + event.AddProperty(ics.ComponentPropertyFreebusy, string(status)) + } + + categories := data.GetCategories() + for _, category := range categories { + event.AddProperty(ics.ComponentPropertyCategories, category) + } + + // According to the RFC, this property may be used in a calendar + // component to convey a location where a more dynamic rendition of + // the calendar information associated with the calendar component + // can be found. + url := ptr.Val(data.GetWebLink()) + if len(url) > 0 { + event.SetURL(url) + } + + organizer := data.GetOrganizer() + if organizer != nil { + name := ptr.Val(organizer.GetEmailAddress().GetName()) + addr := ptr.Val(organizer.GetEmailAddress().GetAddress()) + + // TODO: What to do if we only have a name? + if len(name) > 0 && len(addr) > 0 { + event.SetOrganizer(addr, ics.WithCN(name)) + } else if len(addr) > 0 { + event.SetOrganizer(addr) + } + } + + attendees := data.GetAttendees() + for _, attendee := range attendees { + props := []ics.PropertyParameter{} + + atype := attendee.GetTypeEscaped() + if atype != nil { + var role ics.ParticipationRole + + switch atype.String() { + case "required": + role = ics.ParticipationRoleReqParticipant + case "optional": + role = ics.ParticipationRoleOptParticipant + case "resource": + role = ics.ParticipationRoleNonParticipant + } + + props = append(props, keyValues(string(ics.ParameterRole), string(role))) + } + + name := ptr.Val(attendee.GetEmailAddress().GetName()) + if len(name) > 0 { + props = append(props, ics.WithCN(name)) + } + + // Time when a resp change occurred is not recorded + if attendee.GetStatus() != nil { + resp := ptr.Val(attendee.GetStatus().GetResponse()).String() + if len(resp) > 0 && resp != "none" { + var pstat ics.ParticipationStatus + + switch resp { + case "accepted", "organizer": + pstat = ics.ParticipationStatusAccepted + case "declined": + pstat = ics.ParticipationStatusDeclined + case "tentativelyAccepted": + pstat = ics.ParticipationStatusTentative + case "notResponded": + pstat = ics.ParticipationStatusNeedsAction + } + + props = append(props, keyValues(string(ics.ParameterParticipationStatus), string(pstat))) + } + } + + addr := ptr.Val(attendee.GetEmailAddress().GetAddress()) + event.AddAttendee(addr, props...) + } + + location := getLocationString(data.GetLocation()) + if len(location) > 0 { + event.SetLocation(location) + } + + // TODO Handle different attachment type (file, item and reference) + attachments := data.GetAttachments() + for _, attachment := range attachments { + props := []ics.PropertyParameter{} + contentType := ptr.Val(attachment.GetContentType()) + name := ptr.Val(attachment.GetName()) + + if len(name) > 0 { + // FILENAME does not seem to be parsed by Outlook + props = append(props, + &ics.KeyValues{ + Key: "FILENAME", + Value: []string{name}, + }) + } + + cb, err := attachment.GetBackingStore().Get("contentBytes") + if err != nil { + return "", clues.Wrap(err, "getting attachment content") + } + + content, ok := cb.([]uint8) + if !ok { + return "", clues.NewWC(ctx, "getting attachment content string") + } + + props = append(props, ics.WithEncoding("base64"), ics.WithValue("BINARY")) + if len(contentType) > 0 { + props = append(props, ics.WithFmtType(contentType)) + } + + // TODO: Inline attachments don't show up in Outlook + inline := ptr.Val(attachment.GetIsInline()) + if inline { + cidv, err := attachment.GetBackingStore().Get("contentId") + if err != nil { + return "", clues.Wrap(err, "getting attachment content id") + } + + cid, err := str.AnyToString(cidv) + if err != nil { + return "", clues.Wrap(err, "getting attachment content id string") + } + + props = append(props, keyValues("CID", cid)) + } + + event.AddAttachment(base64.StdEncoding.EncodeToString(content), props...) + } + + return cal.Serialize(), nil +} diff --git a/src/internal/converters/ics/ics_test.go b/src/internal/converters/ics/ics_test.go new file mode 100644 index 000000000..8dc6e249d --- /dev/null +++ b/src/internal/converters/ics/ics_test.go @@ -0,0 +1,1081 @@ +package ics + +// Useful tools +// https://icalendar.org/validator.html +// https://icalendar.org/rrule-tool.html +// https://balsoftware.net/rrule/ + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/microsoft/kiota-abstractions-go/serialization" + kjson "github.com/microsoft/kiota-serialization-json-go" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" +) + +type ICSUnitSuite struct { + tester.Suite +} + +func TestICSUnitSuite(t *testing.T) { + suite.Run(t, &ICSUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *ICSUnitSuite) TestGetLocationString() { + table := []struct { + name string + loc func() models.Locationable + expect string + }{ + { + name: "only displayname", + loc: func() models.Locationable { + loc := models.NewLocation() + loc.SetDisplayName(ptr.To("DisplayName")) + return loc + }, + expect: "DisplayName", + }, + { + name: "full address", + loc: func() models.Locationable { + loc := models.NewLocation() + loc.SetDisplayName(ptr.To("DisplayName")) + + addr := models.NewPhysicalAddress() + addr.SetStreet(ptr.To("Street")) + addr.SetCity(ptr.To("City")) + addr.SetState(ptr.To("State")) + addr.SetCountryOrRegion(ptr.To("Country")) + addr.SetPostalCode(ptr.To("PostalCode")) + + loc.SetAddress(addr) + return loc + }, + expect: "DisplayName, Street, City, State, Country, PostalCode", + }, + { + name: "displayname and street", + loc: func() models.Locationable { + loc := models.NewLocation() + loc.SetDisplayName(ptr.To("DisplayName")) + + addr := models.NewPhysicalAddress() + addr.SetStreet(ptr.To("Street")) + + loc.SetAddress(addr) + return loc + }, + expect: "DisplayName, Street", + }, + { + name: "only street", + loc: func() models.Locationable { + loc := models.NewLocation() + + addr := models.NewPhysicalAddress() + addr.SetStreet(ptr.To("Street")) + + loc.SetAddress(addr) + return loc + }, + expect: "Street", + }, + { + name: "displayname, city, country", + loc: func() models.Locationable { + loc := models.NewLocation() + loc.SetDisplayName(ptr.To("DisplayName")) + + addr := models.NewPhysicalAddress() + addr.SetCity(ptr.To("City")) + addr.SetCountryOrRegion(ptr.To("Country")) + + loc.SetAddress(addr) + return loc + }, + expect: "DisplayName, City, Country", + }, + } + + for _, tt := range table { + suite.Run(tt.name, func() { + assert.Equal(suite.T(), tt.expect, getLocationString(tt.loc())) + }) + } +} + +func (suite *ICSUnitSuite) TestGetUTCTime() { + table := []struct { + name string + timestamp string + timezone string + time time.Time + errCheck require.ErrorAssertionFunc + }{ + { + name: "valid time in UTC", + timestamp: "2021-01-01T12:00:00Z", + timezone: "UTC", + time: time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC), + errCheck: require.NoError, + }, + { + name: "valid time in IST", + timestamp: "2021-01-01T12:00:00Z", + timezone: "India Standard Time", + time: time.Date(2021, 1, 1, 6, 30, 0, 0, time.UTC), + errCheck: require.NoError, + }, + { + name: "invalid time", + timestamp: "invalid", + timezone: "UTC", + time: time.Time{}, + errCheck: require.Error, + }, + { + name: "invalid timezone", + timestamp: "2021-01-01T12:00:00Z", + timezone: "invalid", + time: time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC), + errCheck: require.Error, + }, + } + + for _, tt := range table { + suite.Run(tt.name, func() { + t, err := getUTCTime(tt.timestamp, tt.timezone) + tt.errCheck(suite.T(), err) + + if !tt.time.Equal(time.Time{}) { + assert.Equal(suite.T(), tt.time, t) + } + }) + } +} + +func (suite *ICSUnitSuite) TestGetRecurrencePattern() { + table := []struct { + name string + recurrence func() models.PatternedRecurrenceable + expect string + errCheck require.ErrorAssertionFunc + }{ + { + name: "daily", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("daily") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=DAILY;INTERVAL=1", + errCheck: require.NoError, + }, + { + name: "weekly", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("weekly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=WEEKLY;INTERVAL=1", + errCheck: require.NoError, + }, + { + name: "weekly with end date", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("weekly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + rng := models.NewRecurrenceRange() + + rrtype, err := models.ParseRecurrenceRangeType("endDate") + require.NoError(suite.T(), err) + + rng.SetTypeEscaped(rrtype.(*models.RecurrenceRangeType)) + + edate := serialization.NewDateOnly(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + rng.SetEndDate(edate) + rng.SetRecurrenceTimeZone(ptr.To("UTC")) + + rec.SetPattern(pat) + rec.SetRangeEscaped(rng) + + return rec + }, + expect: "FREQ=WEEKLY;INTERVAL=1;UNTIL=20210101T000000Z", + errCheck: require.NoError, + }, + { + name: "weekly with end count", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("weekly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + rng := models.NewRecurrenceRange() + + rrtype, err := models.ParseRecurrenceRangeType("numbered") + require.NoError(suite.T(), err) + + rng.SetTypeEscaped(rrtype.(*models.RecurrenceRangeType)) + + rng.SetNumberOfOccurrences(ptr.To(int32(10))) + rng.SetRecurrenceTimeZone(ptr.To("UTC")) + + rec.SetPattern(pat) + rec.SetRangeEscaped(rng) + + return rec + }, + expect: "FREQ=WEEKLY;INTERVAL=1;COUNT=10", + errCheck: require.NoError, + }, + { + name: "days of week", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("weekly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + days := []models.DayOfWeek{ + models.MONDAY_DAYOFWEEK, + models.WEDNESDAY_DAYOFWEEK, + models.THURSDAY_DAYOFWEEK, + } + + pat.SetDaysOfWeek(days) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,TH", + errCheck: require.NoError, + }, + { + name: "daily with custom interval", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("daily") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(2))) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=DAILY;INTERVAL=2", + errCheck: require.NoError, + }, + { + name: "day of month", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("absoluteMonthly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + pat.SetDayOfMonth(ptr.To(int32(5))) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=5", + errCheck: require.NoError, + }, + { + name: "every 3rd august", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("absoluteYearly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(3))) + + pat.SetMonth(ptr.To(int32(8))) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=YEARLY;INTERVAL=3;BYMONTH=8", + errCheck: require.NoError, + }, + { + name: "first friday of august every year", + recurrence: func() models.PatternedRecurrenceable { + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("relativeYearly") + require.NoError(suite.T(), err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + pat.SetMonth(ptr.To(int32(8))) + pat.SetDaysOfWeek([]models.DayOfWeek{models.FRIDAY_DAYOFWEEK}) + + wi, err := models.ParseWeekIndex("first") + require.NoError(suite.T(), err) + pat.SetIndex(wi.(*models.WeekIndex)) + + rec.SetPattern(pat) + + return rec + }, + expect: "FREQ=YEARLY;INTERVAL=1;BYMONTH=8;BYDAY=1FR", + errCheck: require.NoError, + }, + // TODO(meain): could still use more tests for edge cases of time + } + + for _, tt := range table { + suite.Run(tt.name, func() { + ctx, flush := tester.NewContext(suite.T()) + defer flush() + + rec, err := getRecurrencePattern(ctx, tt.recurrence()) + tt.errCheck(suite.T(), err) + + assert.Equal(suite.T(), tt.expect, rec) + }) + } +} + +func baseEvent() *models.Event { + e := models.NewEvent() + + e.SetId(ptr.To("mango")) + e.SetSubject(ptr.To("Subject")) + + start := models.NewDateTimeTimeZone() + start.SetDateTime(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339))) + start.SetTimeZone(ptr.To("UTC")) + + end := models.NewDateTimeTimeZone() + end.SetDateTime(ptr.To(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC).Format(time.RFC3339))) + end.SetTimeZone(ptr.To("UTC")) + + e.SetStart(start) + e.SetEnd(end) + + return e +} + +func (suite *ICSUnitSuite) TestEventConversion() { + t := suite.T() + + table := []struct { + name string + event func() *models.Event + check func(string) + }{ + { + name: "simple event", + event: func() *models.Event { + return baseEvent() + }, + check: func(out string) { + assert.Contains(t, out, "BEGIN:VCALENDAR", "beginning of calendar") + assert.Contains(t, out, "VERSION:2.0", "version") + assert.Contains(t, out, "PRODID:-//Alcion//Corso", "prodid") + assert.Contains(t, out, "BEGIN:VEVENT", "beginning of event") + assert.Contains(t, out, "UID:mango", "uid") + assert.Contains(t, out, "SUMMARY:Subject", "summary") + assert.Contains(t, out, "DTSTART:20210101T120000Z", "start time") + assert.Contains(t, out, "DTEND:20210102T130000Z", "end time") + assert.Contains(t, out, "END:VEVENT", "end of event") + assert.Contains(t, out, "END:VCALENDAR", "end of calendar") + }, + }, + { + name: "event with created and modified time", + event: func() *models.Event { + e := baseEvent() + + e.SetCreatedDateTime(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC))) + e.SetLastModifiedDateTime(ptr.To(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC))) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "CREATED:20210101T120000Z", "created time") + assert.Contains(t, out, "LAST-MODIFIED:20210102T130000Z", "modified time") + }, + }, + { + name: "all day events", + event: func() *models.Event { + e := baseEvent() + + e.SetIsAllDay(ptr.To(true)) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "DTSTART;VALUE=DATE:20210101", "start time") + assert.Contains(t, out, "DTEND;VALUE=DATE:20210102", "end time") + }, + }, + { + // All time values should get converted to UTC + name: "start and end with different timezone", + event: func() *models.Event { + e := baseEvent() + + start := models.NewDateTimeTimeZone() + start.SetDateTime(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339))) + start.SetTimeZone(ptr.To("India Standard Time")) + + end := models.NewDateTimeTimeZone() + end.SetDateTime(ptr.To(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC).Format(time.RFC3339))) + end.SetTimeZone(ptr.To("Korea Standard Time")) + + e.SetStart(start) + e.SetEnd(end) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "DTSTART:20210101T063000Z", "start time") + assert.Contains(t, out, "DTEND:20210102T040000Z", "end time") + }, + }, + { + name: "daily event", + event: func() *models.Event { + e := baseEvent() + + rec := models.NewPatternedRecurrence() + pat := models.NewRecurrencePattern() + + typ, err := models.ParseRecurrencePatternType("daily") + require.NoError(t, err) + + pat.SetTypeEscaped(typ.(*models.RecurrencePatternType)) + pat.SetInterval(ptr.To(int32(1))) + + rec.SetPattern(pat) + + e.SetRecurrence(rec) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "RRULE:FREQ=DAILY;INTERVAL=1", "recurrence rule") + }, + }, + { + name: "cancelled event", + event: func() *models.Event { + e := baseEvent() + + e.SetIsCancelled(ptr.To(true)) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "STATUS:CANCELLED", "cancelled status") + }, + }, + { + name: "draft event", + event: func() *models.Event { + e := baseEvent() + + e.SetIsDraft(ptr.To(true)) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "STATUS:DRAFT", "draft status") + }, + }, + { + name: "text body", + event: func() *models.Event { + e := baseEvent() + + body := models.NewItemBody() + btype, err := models.ParseBodyType("text") + require.NoError(t, err, "parse body type") + + body.SetContentType(btype.(*models.BodyType)) + body.SetContent(ptr.To("body")) + + e.SetBody(body) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "DESCRIPTION:body", "body") + }, + }, + { + name: "html body", + event: func() *models.Event { + e := baseEvent() + + body := models.NewItemBody() + btype, err := models.ParseBodyType("html") + require.NoError(t, err, "parse body type") + + body.SetContentType(btype.(*models.BodyType)) + body.SetContent(ptr.To("body")) + + e.SetBodyPreview(ptr.To("body preview")) + e.SetBody(body) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "DESCRIPTION:body preview", "body preview") + assert.Contains(t, out, "X-ALT-DESC;FMTTYPE=text/html:body", "body") + }, + }, + { + name: "free busy free", + event: func() *models.Event { + e := baseEvent() + + fbs, err := models.ParseFreeBusyStatus("free") + require.NoError(t, err, "parse free busy status") + + e.SetShowAs(fbs.(*models.FreeBusyStatus)) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "FREEBUSY:FREE", "free busy status") + }, + }, + { + name: "free busy unknown", + event: func() *models.Event { + e := baseEvent() + + fbs, err := models.ParseFreeBusyStatus("unknown") + require.NoError(t, err, "parse free busy status") + + e.SetShowAs(fbs.(*models.FreeBusyStatus)) + + return e + }, + check: func(out string) { + assert.NotContains(t, out, "FREEBUSY", "free busy status") + }, + }, + { + name: "free busy oof", + event: func() *models.Event { + e := baseEvent() + + fbs, err := models.ParseFreeBusyStatus("oof") + require.NoError(t, err, "parse free busy status") + + e.SetShowAs(fbs.(*models.FreeBusyStatus)) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "FREEBUSY:BUSY-UNAVAILABLE", "free busy status") + }, + }, + { + name: "categories", + event: func() *models.Event { + e := baseEvent() + + e.SetCategories([]string{"cat1", "cat2"}) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "CATEGORIES:cat1", "categories") + assert.Contains(t, out, "CATEGORIES:cat2", "categories") + }, + }, + { + name: "weblink", + event: func() *models.Event { + e := baseEvent() + + e.SetWebLink(ptr.To("https://example.com")) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "URL:https://example.com", "weblink") + }, + }, + { + name: "organizer just email", + event: func() *models.Event { + e := baseEvent() + + org := models.NewRecipient() + + addr := models.NewEmailAddress() + addr.SetAddress(ptr.To("user@provider.co")) + org.SetEmailAddress(addr) + + e.SetOrganizer(org) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "ORGANIZER:user@provider.co", "organizer") + }, + }, + { + name: "organizer name and email", + event: func() *models.Event { + e := baseEvent() + + org := models.NewRecipient() + + addr := models.NewEmailAddress() + addr.SetAddress(ptr.To("user@provider.co")) + addr.SetName(ptr.To("User")) + + org.SetEmailAddress(addr) + + e.SetOrganizer(org) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "ORGANIZER;CN=User:user@provider.co", "organizer") + }, + }, + { + name: "location", + event: func() *models.Event { + e := baseEvent() + + // full test is done separately + loc := models.NewLocation() + loc.SetDisplayName(ptr.To("DisplayName")) + + e.SetLocation(loc) + + return e + }, + check: func(out string) { + assert.Contains(t, out, "LOCATION:DisplayName", "location") + }, + }, + } + + for _, tt := range table { + suite.Run(tt.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + // convert event to bytes + writer := kjson.NewJsonSerializationWriter() + defer writer.Close() + + err := writer.WriteObjectValue("", tt.event()) + require.NoError(t, err, "serializing contact") + + bts, err := writer.GetSerializedContent() + require.NoError(t, err, "getting serialized content") + + e, err := FromJSON(ctx, bts) + require.NoError(t, err, "converting to ics") + + tt.check(e) + }) + } +} + +// checkAttendee checks the ATTENDEE field +// This is required instead of a string check as the fields might +// not always be in the same order +func checkAttendee(t *testing.T, out, check, msg string) { + splitFunc := func(c rune) bool { + return c == ';' || c == ':' + } + + line := "" + + for _, l := range strings.Split(out, "\r\n") { + if !strings.HasPrefix(l, "ATTENDEE") { + continue + } + + splits := strings.Split(check, ":") + if strings.Contains(l, splits[len(splits)-1]) { + line = l + break + } + } + + if len(line) == 0 { + assert.Fail(t, fmt.Sprintf("line not found %s", msg)) + return + } + + as := strings.FieldsFunc(line, splitFunc) + bs := strings.FieldsFunc(check, splitFunc) + + assert.Equal(t, len(as), len(bs), fmt.Sprintf("length of fields of %s", msg)) + assert.ElementsMatch(t, as, bs, fmt.Sprintf("fields %s", msg)) +} + +func (suite *ICSUnitSuite) TestAttendees() { + t := suite.T() + + table := []struct { + name string + att [][]string + check func(string) + }{ + { + name: "single attendee", + // email, role, participation + att: [][]string{{"one@att.co", "", ""}}, + check: func(out string) { + checkAttendee(t, out, "ATTENDEE;CN=one:mailto:one@att.co", "attendee") + }, + }, + { + name: "single attendee with role and participation", + att: [][]string{{"one@att.co", "required", "declined"}}, + check: func(out string) { + checkAttendee( + t, + out, + "ATTENDEE;ROLE=REQ-PARTICIPANT;CN=one;PARTSTAT=DECLINED:mailto:one@att.co", + "attendee") + }, + }, + { + name: "multiple attendees", + att: [][]string{ + {"one@att.co", "", ""}, + {"two@att.co", "optional", "accepted"}, + {"th@att.co", "resource", "notResponded"}, // th instead of three to prevent split + {"four@att.co", "required", "tentativelyAccepted"}, + }, + check: func(out string) { + checkAttendee(t, out, "ATTENDEE;CN=one:mailto:one@att.co", "attendee one") + checkAttendee( + t, + out, + "ATTENDEE;ROLE=OPT-PARTICIPANT;CN=two;PARTSTAT=ACCEPTED:mailto:two@att.co", + "attendee two") + checkAttendee( + t, + out, + "ATTENDEE;ROLE=NON-PARTICIPANT;CN=th;PARTSTAT=NEEDS-ACTION:mailto:th@att.co", + "attendee th") + checkAttendee( + t, + out, + "ATTENDEE;ROLE=REQ-PARTICIPANT;CN=four;PARTSTAT=TENTATIVE:mailto:four@att.co", + "attendee four") + }, + }, + } + + for _, tt := range table { + suite.Run(tt.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + // convert event to bytes + writer := kjson.NewJsonSerializationWriter() + defer writer.Close() + + e := baseEvent() + + atts := make([]models.Attendeeable, len(tt.att)) + + for i, a := range tt.att { + att := models.NewAttendee() + + addr := models.NewEmailAddress() + addr.SetAddress(ptr.To(a[0])) + + name := strings.Split(a[0], "@")[0] + addr.SetName(ptr.To(name)) + + att.SetEmailAddress(addr) + + if len(a[1]) > 0 { + atype, err := models.ParseAttendeeType(a[1]) + require.NoError(t, err, "parse attendee type") + + att.SetTypeEscaped(atype.(*models.AttendeeType)) + } + + if len(a[2]) > 0 { + stat := models.NewResponseStatus() + resp, err := models.ParseResponseType(a[2]) + require.NoError(t, err, "parse response type") + + stat.SetResponse(resp.(*models.ResponseType)) + att.SetStatus(stat) + } + + atts[i] = att + } + + e.SetAttendees(atts) + + err := writer.WriteObjectValue("", e) + require.NoError(t, err, "serializing contact") + + bts, err := writer.GetSerializedContent() + require.NoError(t, err, "getting serialized content") + + out, err := FromJSON(ctx, bts) + require.NoError(t, err, "converting to ics") + + tt.check(out) + }) + } +} + +func checkAttachment(t *testing.T, out, check, msg string) { + var ( + attachments = []string{} + inAttachment = false + attachment = "" + checkSplits = strings.Split(check, ":")[0] + filenameSegment = "" + attachmentToCheck = "" + ) + + for _, l := range strings.Split(out, "\r\n") { + if strings.HasPrefix(l, "ATTACH") { + inAttachment = true + attachment = l + } else if inAttachment { + if len(l) > 1 || l[0] == ' ' { + attachment += l[1:] + } + + inAttachment = false + + attachments = append(attachments, attachment) + } + } + + if inAttachment { + attachments = append(attachments, attachment) + } + + if len(attachments) == 0 { + assert.Fail(t, fmt.Sprintf("no attachments found: %s", msg)) + return + } + + for _, s := range strings.Split(checkSplits, ";") { + if strings.HasPrefix(s, "FILENAME") { + filenameSegment = s + break + } + } + + if len(filenameSegment) == 0 { + assert.Fail(t, fmt.Sprintf("filename not found %s", msg)) + return + } + + for _, a := range attachments { + if strings.Contains(a, filenameSegment) { + attachmentToCheck = a + break + } + } + + if len(attachmentToCheck) == 0 { + assert.Fail(t, fmt.Sprintf("attachment not found: %s", msg)) + return + } + + splitFunc := func(c rune) bool { + return c == ';' || c == ':' + } + + as := strings.FieldsFunc(attachmentToCheck, splitFunc) + bs := strings.FieldsFunc(check, splitFunc) + + assert.Equal(t, len(as), len(bs), fmt.Sprintf("length of fields of %s", msg)) + assert.ElementsMatch(t, as, bs, fmt.Sprintf("fields %s", msg)) +} + +func (suite *ICSUnitSuite) TestAttachments() { + t := suite.T() + + type attachment struct { + cid string // contentid + name string + ctype string + content string + inline bool + } + + table := []struct { + name string + att []attachment + check func(string) + }{ + { + name: "single attachment", + att: []attachment{ + {"1", "one", "text/plain", "content", false}, + }, + check: func(out string) { + checkAttachment( + t, + out, + "ATTACH;VALUE=BINARY;FMTTYPE=text/plain;FILENAME=one;ENCODING=base64:Y29udGVudA==", + "attachment") + }, + }, + { + name: "multiple attachments", + att: []attachment{ + {"1", "one", "text/plain", "content", false}, + {"2", "two", "text/html", "content", false}, + }, + check: func(out string) { + base := "ATTACH;FILENAME=one;ENCODING=base64;VALUE=BINARY;FMTTYPE=text/plain:" + content := base64.StdEncoding.EncodeToString([]byte("content")) + + checkAttachment( + t, + out, + base+content, + "attachment one") + + base = "ATTACH;FILENAME=two;ENCODING=base64;VALUE=BINARY;FMTTYPE=text/html:" + content = base64.StdEncoding.EncodeToString([]byte("content")) + checkAttachment( + t, + out, + base+content, + "attachment two") + }, + }, + } + + for _, tt := range table { + suite.Run(tt.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + // convert event to bytes + writer := kjson.NewJsonSerializationWriter() + defer writer.Close() + + e := baseEvent() + + err := writer.WriteObjectValue("", e) + require.NoError(t, err, "serializing contact") + + bts, err := writer.GetSerializedContent() + require.NoError(t, err, "getting serialized content") + + parsed := map[string]any{} + err = json.Unmarshal(bts, &parsed) + require.NoError(t, err, "unmarshalling json") + + // could not add attachment content without doing this + atts := make([]map[string]any, len(tt.att)) + + for i, a := range tt.att { + att := map[string]any{ + "@odata.type": "#microsoft.graph.fileAttachment", + "name": a.name, + "contentType": a.ctype, + "contentBytes": base64.StdEncoding.EncodeToString([]byte(a.content)), + "contentId": a.cid, + "isInline": a.inline, + } + + atts[i] = att + } + + parsed["attachments"] = atts + + bts, err = json.Marshal(parsed) + require.NoError(t, err, "marshalling json") + + out, err := FromJSON(ctx, bts) + require.NoError(t, err, "converting to ics") + + tt.check(out) + }) + } +} diff --git a/src/internal/m365/collection/exchange/export.go b/src/internal/m365/collection/exchange/export.go index 36e2520d9..6c3c68463 100644 --- a/src/internal/m365/collection/exchange/export.go +++ b/src/internal/m365/collection/exchange/export.go @@ -8,6 +8,7 @@ import ( "github.com/alcionai/clues" "github.com/alcionai/corso/src/internal/converters/eml" + "github.com/alcionai/corso/src/internal/converters/ics" "github.com/alcionai/corso/src/internal/converters/vcf" "github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/pkg/control" @@ -48,12 +49,16 @@ func streamItems( for _, rc := range drc { ictx := clues.Add(ctx, "path_short_ref", rc.FullPath().ShortRef()) - - ext := ".eml" category := rc.FullPath().Category() + ext := "" - if category == path.ContactsCategory { + switch category { + case path.EmailCategory: + ext = ".eml" + case path.ContactsCategory: ext = ".vcf" + case path.EventsCategory: + ext = ".ics" } for item := range rc.Items(ictx, errs) { @@ -111,6 +116,20 @@ func streamItems( Error: err, } + continue + } + case path.EventsCategory: + outData, err = ics.FromJSON(ctx, content) + if err != nil { + err = clues.Wrap(err, "converting to ics") + + logger.CtxErr(ctx, err).Info("processing collection item") + + ch <- export.Item{ + ID: id, + Error: err, + } + continue } } diff --git a/src/pkg/dttm/dttm.go b/src/pkg/dttm/dttm.go index b768e92f8..fc21e3771 100644 --- a/src/pkg/dttm/dttm.go +++ b/src/pkg/dttm/dttm.go @@ -69,6 +69,7 @@ var ( SafeForTesting, HumanReadable, HumanReadableDriveItem, + M365DateTimeTimeZone, Legacy, TabularOutput, ClippedHuman,