diff --git a/README.md b/README.md index dccc64e..717edcc 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ asdf plugin add calendarsync https://github.com/FeryET/asdf-calendarsync.git ## finally asdf install calendarsync ``` + Note: The `asdf` plugin is not managed by inovex, but is provided by a CalendarSync user. inovex assumes no responsibility for proper provisioning. ## First Time Execution @@ -54,7 +55,7 @@ Then, start the app using `CALENDARSYNC_ENCRYPTION_KEY= ./ca The app will create a file in the execution folder called `auth-storage.yaml`. In this file the OAuth2 Credentials will be saved encrypted by your `$CALENDARSYNC_ENCRYPTION_KEY`. ----- +--- # Configuration @@ -146,19 +147,21 @@ transformerOrder = []string{ "KeepTitle", "PrefixTitle", "ReplaceTitle", +"SetVisibility", } -| **Name** | **Description** | **Configuration** | -|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------| -| `KeepAttendees` | Synchronizes the list of attendees. If `UseEmailAsDisplayName` is set to `true`, the email is used in the attendee list. Do not use when the Outlook Adapter is used as a sink as there is no way to suppress mail invitations. | `config.UseEmailAsDisplayName`, default `false` | -| `KeepLocation` | Synchronizes the location of the event. | – | -| `KeepReminders` | Synchronizes event reminders. | – | -| `KeepDescription` | Synchronizes the description of the event. | – | -| `KeepMeetingLink` | Adds the meeting link of the original meeting to the description of the event. | – | -| `AddOriginalLink` | Adds the link to the original calendar event to the description of the event. | – | -| `KeepTitle` | Synchronizes the event's title. Without this transformer, the title is set to `CalendarSync Event` | – | -| `PrefixTitle` | Adds the configured prefix to the title. | `config.Prefix`, default `""` | -| `ReplaceTitle` | Replaces the title with the configured string. Does not make sense to be used with `KeepTitle` or `PrefixTitle` | `config.NewTitle`, default `"CalendarSync Event"` | +| **Name** | **Description** | **Configuration** | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `KeepAttendees` | Synchronizes the list of attendees. If `UseEmailAsDisplayName` is set to `true`, the email is used in the attendee list. Do not use when the Outlook Adapter is used as a sink as there is no way to suppress mail invitations. | `config.UseEmailAsDisplayName`, default `false` | +| `KeepLocation` | Synchronizes the location of the event. | – | +| `KeepReminders` | Synchronizes event reminders. | – | +| `KeepDescription` | Synchronizes the description of the event. | – | +| `KeepMeetingLink` | Adds the meeting link of the original meeting to the description of the event. | – | +| `AddOriginalLink` | Adds the link to the original calendar event to the description of the event. | – | +| `KeepTitle` | Synchronizes the event's title. Without this transformer, the title is set to `CalendarSync Event` | – | +| `PrefixTitle` | Adds the configured prefix to the title. | `config.Prefix`, default `""` | +| `ReplaceTitle` | Replaces the title with the configured string. Does not make sense to be used with `KeepTitle` or `PrefixTitle` | `config.NewTitle`, default `"CalendarSync Event"` | +| `SetVisibility` | Sets the visibility of synced events. Supported values: `default`, `public`, `private`, `confidential`. Maps to iCalendar `CLASS` property. Supported by Google Calendar and Outlook adapters (Outlook maps `public` to `normal`). | `config.Visibility`, default `"default"` | Example configuration: @@ -176,6 +179,9 @@ transformations: - name: KeepAttendees config: UseEmailAsDisplayName: true + - name: SetVisibility + config: + Visibility: "private" ``` ## Filters @@ -232,7 +238,7 @@ Corporation # Relevant RFCs and Links -[RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545) Internet Calendaring +[RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545) Internet Calendaring and Scheduling Core Object Specification (iCalendar) is used in the Google calendar API to denote recurrence patterns. CalDav [RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791) uses the dateformat diff --git a/example.sync.yaml b/example.sync.yaml index 4bd3d3e..57329b3 100644 --- a/example.sync.yaml +++ b/example.sync.yaml @@ -56,6 +56,10 @@ transformations: - name: KeepAttendees config: UseEmailAsDisplayName: true + # Set event visibility: "default", "public", "private", or "confidential" + # - name: SetVisibility + # config: + # Visibility: "private" # Filters remove events from being synced due to different criteria filters: diff --git a/internal/adapter/google/client.go b/internal/adapter/google/client.go index 8f6d22b..3710b0d 100644 --- a/internal/adapter/google/client.go +++ b/internal/adapter/google/client.go @@ -112,6 +112,12 @@ func (g *GCalClient) CreateEvent(ctx context.Context, event models.Event) error } } + // Set visibility, default to "default" if empty + visibility := event.Visibility + if visibility == "" { + visibility = "default" + } + call, err := retry(ctx, func() (*calendar.Event, error) { g.RateLimiter.Take() return g.Client.Events.Insert(g.CalendarId, &calendar.Event{ @@ -123,6 +129,7 @@ func (g *GCalClient) CreateEvent(ctx context.Context, event models.Event) error ExtendedProperties: extProperties, Attendees: calendarAttendees, Reminders: &calendarReminders, + Visibility: visibility, }).Context(ctx).SendUpdates("none").Do() }) if err != nil { @@ -165,6 +172,12 @@ func (g *GCalClient) UpdateEvent(ctx context.Context, event models.Event) error } } + // Set visibility, default to "default" if empty + visibility := event.Visibility + if visibility == "" { + visibility = "default" + } + _, err := retry(ctx, func() (*calendar.Event, error) { g.RateLimiter.Take() return g.Client.Events.Update(g.CalendarId, event.ID, &calendar.Event{ @@ -176,6 +189,7 @@ func (g *GCalClient) UpdateEvent(ctx context.Context, event models.Event) error ExtendedProperties: extProperties, Attendees: calendarAttendees, Reminders: calendarReminders, + Visibility: visibility, }).Context(ctx).SendUpdates("none").Do() }) if isNotFound(err) { diff --git a/internal/adapter/google/event.go b/internal/adapter/google/event.go index a6776c6..9abdf85 100644 --- a/internal/adapter/google/event.go +++ b/internal/adapter/google/event.go @@ -38,6 +38,12 @@ func calendarEventToEvent(e *calendar.Event, adapterSourceID string) models.Even } } + // Set visibility, default to "default" if not specified + visibility := e.Visibility + if visibility == "" { + visibility = "default" + } + return models.Event{ ICalUID: e.ICalUID, ID: e.Id, @@ -52,6 +58,7 @@ func calendarEventToEvent(e *calendar.Event, adapterSourceID string) models.Even Reminders: reminders, MeetingLink: e.HangoutLink, Accepted: hasEventAccepted, + Visibility: visibility, } } diff --git a/internal/adapter/outlook_http/client.go b/internal/adapter/outlook_http/client.go index 2064e74..1a9b9e2 100644 --- a/internal/adapter/outlook_http/client.go +++ b/internal/adapter/outlook_http/client.go @@ -21,6 +21,36 @@ const ( ExtensionName = "inovex.calendarsync.meta" ) +// visibilityToSensitivity maps CalendarSync visibility values to Outlook sensitivity values +// Visibility: "default", "public", "private", "confidential" (iCalendar CLASS) +// Sensitivity: "normal", "personal", "private", "confidential" (Outlook/Graph API) +func visibilityToSensitivity(visibility string) string { + switch visibility { + case "public", "default", "": + return "normal" + case "private": + return "private" + case "confidential": + return "confidential" + default: + return "normal" + } +} + +// sensitivityToVisibility maps Outlook sensitivity values to CalendarSync visibility values +func sensitivityToVisibility(sensitivity string) string { + switch sensitivity { + case "normal", "personal", "": + return "default" + case "private": + return "private" + case "confidential": + return "confidential" + default: + return "default" + } +} + // OutlookClient implements the OutlookCalendarClient interface type OutlookClient struct { Client *http.Client @@ -249,6 +279,10 @@ func (o OutlookClient) eventToOutlookEvent(e models.Event) (oe Event) { // we currently use the first reminder in the list, this may result in data loss outlookEvent.ReminderMinutesBeforeStart = int(e.StartTime.Sub(e.Reminders[0].Trigger.PointInTime).Minutes()) } + + // Map visibility to Outlook sensitivity + outlookEvent.Sensitivity = visibilityToSensitivity(e.Visibility) + return outlookEvent } @@ -303,6 +337,7 @@ func (o OutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e Reminders: reminders, MeetingLink: oe.OnlineMeetingUrl, Accepted: hasEventAccepted, + Visibility: sensitivityToVisibility(oe.Sensitivity), } if oe.IsAllDay { diff --git a/internal/adapter/outlook_http/models.go b/internal/adapter/outlook_http/models.go index 1564caf..a66b400 100644 --- a/internal/adapter/outlook_http/models.go +++ b/internal/adapter/outlook_http/models.go @@ -28,6 +28,7 @@ type Event struct { IsAllDay bool `json:"isAllDay"` OnlineMeetingUrl string `json:"onlineMeetingUrl"` ResponseStatus ResponseStatus `json:"responseStatus,omitempty"` + Sensitivity string `json:"sensitivity,omitempty"` } type Extensions struct { diff --git a/internal/models/event.go b/internal/models/event.go index b916f45..bd4efc4 100644 --- a/internal/models/event.go +++ b/internal/models/event.go @@ -28,6 +28,7 @@ type Event struct { Reminders Reminders MeetingLink string Accepted bool + Visibility string // Event visibility: "default", "public", "private", "confidential" } type Reminders []Reminder @@ -113,6 +114,7 @@ func (e *Event) Overwrite(source Event) Event { e.Location = source.Location e.Reminders = source.Reminders e.MeetingLink = source.MeetingLink + e.Visibility = source.Visibility return *e } @@ -209,6 +211,11 @@ func IsSameEvent(a, b Event) bool { } } + if a.Visibility != b.Visibility { + log.Debugf("Visibility of Source Event %s at %s changed from %s to %s", a.Title, a.StartTime, b.Visibility, a.Visibility) + return false + } + return true } diff --git a/internal/sync/transformer.go b/internal/sync/transformer.go index a9864fd..7099466 100644 --- a/internal/sync/transformer.go +++ b/internal/sync/transformer.go @@ -39,6 +39,7 @@ var ( "KeepLocation": &transformation.KeepLocation{}, "KeepAttendees": &transformation.KeepAttendees{UseEmailAsDisplayName: false}, "KeepReminders": &transformation.KeepReminders{}, + "SetVisibility": &transformation.SetVisibility{Visibility: "default"}, } // this is the order of the transformers in which they get evaluated @@ -52,6 +53,7 @@ var ( "KeepTitle", "PrefixTitle", "ReplaceTitle", + "SetVisibility", } ) diff --git a/internal/transformation/setVisibility.go b/internal/transformation/setVisibility.go new file mode 100644 index 0000000..7b73dd2 --- /dev/null +++ b/internal/transformation/setVisibility.go @@ -0,0 +1,33 @@ +package transformation + +import ( + "github.com/inovex/CalendarSync/internal/models" +) + +// SetVisibility allows to set the visibility of an event. +// Supported values: "default", "public", "private", "confidential" +type SetVisibility struct { + Visibility string +} + +func (t *SetVisibility) Name() string { + return "SetVisibility" +} + +func (t *SetVisibility) Transform(_ models.Event, sink models.Event) (models.Event, error) { + // Validate visibility value + validVisibilities := map[string]bool{ + "default": true, + "public": true, + "private": true, + "confidential": true, + } + + if t.Visibility != "" && validVisibilities[t.Visibility] { + sink.Visibility = t.Visibility + } + return sink, nil +} + + + diff --git a/internal/transformation/setVisibility_test.go b/internal/transformation/setVisibility_test.go new file mode 100644 index 0000000..34463e7 --- /dev/null +++ b/internal/transformation/setVisibility_test.go @@ -0,0 +1,78 @@ +package transformation + +import ( + "testing" + + "github.com/inovex/CalendarSync/internal/models" + "github.com/stretchr/testify/assert" +) + +// verify transformer SetVisibility +func TestSetVisibility_Transform(t *testing.T) { + tt := []struct { + name string + visibility string + expectedVisibility string + }{ + { + name: "set visibility to private", + visibility: "private", + expectedVisibility: "private", + }, + { + name: "set visibility to public", + visibility: "public", + expectedVisibility: "public", + }, + { + name: "set visibility to confidential", + visibility: "confidential", + expectedVisibility: "confidential", + }, + { + name: "set visibility to default", + visibility: "default", + expectedVisibility: "default", + }, + { + name: "invalid visibility is ignored", + visibility: "invalid", + expectedVisibility: "", + }, + { + name: "empty visibility is ignored", + visibility: "", + expectedVisibility: "", + }, + } + + t.Parallel() + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + expectedTitle := "Test Event" + + source := models.Event{ + Title: expectedTitle, + Visibility: "default", + } + sink := models.Event{ + Title: expectedTitle, + Visibility: "", + } + + transformer := SetVisibility{Visibility: tc.visibility} + event, err := transformer.Transform(source, sink) + + assert.Nil(t, err) + + expectedEvent := models.Event{ + Title: expectedTitle, + Visibility: tc.expectedVisibility, + } + assert.Equal(t, expectedEvent, event) + }) + } +} + + +