Events export as ICS (#4958)

There are a few more pending items. I've added TODO entries for them. The PR was getting pretty big and so I thought I would address them in a followup PR.

---

#### Does this PR need a docs update or release note?

- [ ]  Yes, it's included
- [ ] 🕐 Yes, but in a later PR
- [ ]  No

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [ ] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Supportability/Tests
- [ ] 💻 CI/Deployment
- [ ] 🧹 Tech Debt/Cleanup

#### Issue(s)

<!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. -->
* https://github.com/alcionai/corso/issues/3890

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [ ]  Unit test
- [ ] 💚 E2E
This commit is contained in:
Abin Simon 2024-01-03 16:14:28 +05:30 committed by GitHub
parent c87533a266
commit b49c124512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1732 additions and 3 deletions

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"github.com/alcionai/corso/src/internal/converters/eml" "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/converters/vcf"
) )
@ -40,6 +41,11 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
case "ics":
out, err = ics.FromJSON(context.Background(), body)
if err != nil {
log.Fatal(err)
}
default: default:
log.Fatal("Unknown target format", to) log.Fatal("Unknown target format", to)
} }

View File

@ -59,6 +59,7 @@ require (
github.com/VividCortex/ewma v1.2.0 // indirect github.com/VividCortex/ewma v1.2.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/andybalholm/brotli v1.0.6 // 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/aws/aws-sdk-go v1.48.6 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect

View File

@ -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/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 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= 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 h1:hnL/TE3eRigirDLrdRE9AWE1ALZSVLAsC4wK8TGsMqk=
github.com/aws/aws-sdk-go v1.48.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 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= 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 h1:bAAUrj2B9v0kMzbAOhzjSmiyDy+rd56r2sy7oEiQLlA=
github.com/cjlapao/common-go v0.0.39/go.mod h1:M3dzazLjTjEtZJbbxoA5ZDiGCiHmpwqW9l4UWaddwOA= 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/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 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= 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= 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/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 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 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 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= 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/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 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-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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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= 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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=

View File

@ -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",
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import (
"github.com/alcionai/clues" "github.com/alcionai/clues"
"github.com/alcionai/corso/src/internal/converters/eml" "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/converters/vcf"
"github.com/alcionai/corso/src/internal/data" "github.com/alcionai/corso/src/internal/data"
"github.com/alcionai/corso/src/pkg/control" "github.com/alcionai/corso/src/pkg/control"
@ -48,12 +49,16 @@ func streamItems(
for _, rc := range drc { for _, rc := range drc {
ictx := clues.Add(ctx, "path_short_ref", rc.FullPath().ShortRef()) ictx := clues.Add(ctx, "path_short_ref", rc.FullPath().ShortRef())
ext := ".eml"
category := rc.FullPath().Category() category := rc.FullPath().Category()
ext := ""
if category == path.ContactsCategory { switch category {
case path.EmailCategory:
ext = ".eml"
case path.ContactsCategory:
ext = ".vcf" ext = ".vcf"
case path.EventsCategory:
ext = ".ics"
} }
for item := range rc.Items(ictx, errs) { for item := range rc.Items(ictx, errs) {
@ -111,6 +116,20 @@ func streamItems(
Error: err, 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 continue
} }
} }

View File

@ -69,6 +69,7 @@ var (
SafeForTesting, SafeForTesting,
HumanReadable, HumanReadable,
HumanReadableDriveItem, HumanReadableDriveItem,
M365DateTimeTimeZone,
Legacy, Legacy,
TabularOutput, TabularOutput,
ClippedHuman, ClippedHuman,