diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e562fb..9ebc4be 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,4 @@ +--- # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4045380..6c0fb46 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,9 +3,9 @@ name: Build on: [ push ] env: - GO_VERSION: '1.20.4' - GO_VERSION_SHORT: '1.20' - GOLANGCI_VERSION: 'v1.52.2' + GO_VERSION: '1.21.0' + GO_VERSION_SHORT: '1.21' + GOLANGCI_VERSION: 'v1.54.0' STATICCHECK_VERSION: '2023.1.3' permissions: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0bab143..31dab9c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,6 +5,9 @@ on: tags: - 'v*' +env: + GO_VERSION: '1.21.0' + permissions: contents: write packages: write @@ -19,6 +22,8 @@ jobs: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} - name: Log in to GitHub container registry uses: docker/login-action@v2 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 10ce928..aa23872 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +--- before: hooks: - go mod tidy diff --git a/README.md b/README.md index fb60a39..2fcd3a0 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,21 @@ transformations: The transformers are applied in a specific order. The order is defined here: [`internal/sync/transformer.go`](./internal/sync/transformer.go) +## Filters + +In some cases events should not be synced. For example, declined events might +create too much noise in the target calendar. These can be filtered by enabling +the corresponding filter. + +```yaml +# Filters remove events from being synced due to different criteria +filters: + # Events where you declined the invitation aren't synced + - name: DeclinedEvents + # Events which cover the full day aren't synced + - name: AllDayEvents +``` + # Cleaning Up You just synced a lot of events in your calendar and decide you want to use a diff --git a/cmd/calendarsync/main.go b/cmd/calendarsync/main.go index 403ecfc..9373f4e 100644 --- a/cmd/calendarsync/main.go +++ b/cmd/calendarsync/main.go @@ -164,7 +164,7 @@ func Run(c *cli.Context) error { } } - controller := sync.NewController(log.Default(), sourceAdapter, sinkAdapter, sync.TransformerFactory(cfg.Transformations)...) + controller := sync.NewController(log.Default(), sourceAdapter, sinkAdapter, sync.TransformerFactory(cfg.Transformations), sync.FilterFactory(cfg.Filters)) if cfg.UpdateConcurrency != 0 { controller.SetConcurrency(cfg.UpdateConcurrency) } diff --git a/docker-compose.yml b/docker-compose.yml index 0f16bf6..abf716d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +--- version: '3.4' services: diff --git a/example.sync.yaml b/example.sync.yaml index b0d0b0f..1dfaadc 100644 --- a/example.sync.yaml +++ b/example.sync.yaml @@ -54,6 +54,13 @@ transformations: config: UseEmailAsDisplayName: true +# Filters remove events from being synced due to different criteria +filters: + # Events where you declined the invitation aren't synced + - name: DeclinedEvents + # Events which cover the full day aren't synced + - name: AllDayEvents + # Perform multiple calendar updates concurrently # Defaults to 1 if not set updateConcurrency: 3 diff --git a/internal/adapter/google/event.go b/internal/adapter/google/event.go index 9f5385b..f3ffca5 100644 --- a/internal/adapter/google/event.go +++ b/internal/adapter/google/event.go @@ -15,7 +15,11 @@ func calendarEventToEvent(e *calendar.Event, adapterSourceID string) models.Even metadata := ensureMetadata(e, adapterSourceID) var attendees []models.Attendee + var hasEventAccepted bool = true for _, eventAttendee := range e.Attendees { + if eventAttendee.Self && eventAttendee.ResponseStatus == "declined" { + hasEventAccepted = false + } attendees = append(attendees, models.Attendee{ Email: eventAttendee.Email, DisplayName: eventAttendee.DisplayName, @@ -47,6 +51,7 @@ func calendarEventToEvent(e *calendar.Event, adapterSourceID string) models.Even Attendees: attendees, Reminders: reminders, MeetingLink: e.HangoutLink, + Accepted: hasEventAccepted, } } diff --git a/internal/adapter/outlook_http/client.go b/internal/adapter/outlook_http/client.go index 223f5c9..ddccc9e 100644 --- a/internal/adapter/outlook_http/client.go +++ b/internal/adapter/outlook_http/client.go @@ -267,6 +267,10 @@ func (o OutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e }, }) } + var hasEventAccepted bool = true + if oe.ResponseStatus.Response == "declined" { + hasEventAccepted = false + } bufEvent = models.Event{ ICalUID: oe.UID, @@ -280,6 +284,7 @@ func (o OutlookClient) outlookEventToEvent(oe Event, adapterSourceID string) (e Attendees: attendees, Reminders: reminders, MeetingLink: oe.OnlineMeetingUrl, + Accepted: hasEventAccepted, } if oe.IsAllDay { diff --git a/internal/adapter/outlook_http/models.go b/internal/adapter/outlook_http/models.go index e59ef3e..4ba9fa6 100644 --- a/internal/adapter/outlook_http/models.go +++ b/internal/adapter/outlook_http/models.go @@ -12,21 +12,22 @@ type EventList struct { } type Event struct { - ID string `json:"id"` - UID string `json:"iCalUId"` - ChangeKey string `json:"changeKey"` - HtmlLink string `json:"webLink"` - Subject string `json:"subject"` - Start Time `json:"start"` - End Time `json:"end"` - Body Body `json:"body,omitempty"` - Attendees []Attendee `json:"attendees,omitempty"` - Location Location `json:"location"` - IsReminderOn bool `json:"isReminderOn"` - ReminderMinutesBeforeStart int `json:"reminderMinutesBeforeStart"` - Extensions []Extensions `json:"extensions"` - IsAllDay bool `json:"isAllDay"` - OnlineMeetingUrl string `json:"onlineMeetingUrl"` + ID string `json:"id"` + UID string `json:"iCalUId"` + ChangeKey string `json:"changeKey"` + HtmlLink string `json:"webLink"` + Subject string `json:"subject"` + Start Time `json:"start"` + End Time `json:"end"` + Body Body `json:"body,omitempty"` + Attendees []Attendee `json:"attendees,omitempty"` + Location Location `json:"location"` + IsReminderOn bool `json:"isReminderOn"` + ReminderMinutesBeforeStart int `json:"reminderMinutesBeforeStart"` + Extensions []Extensions `json:"extensions"` + IsAllDay bool `json:"isAllDay"` + OnlineMeetingUrl string `json:"onlineMeetingUrl"` + ResponseStatus ResponseStatus `json:"responseStatus,omitempty"` } type Extensions struct { @@ -36,6 +37,12 @@ type Extensions struct { models.Metadata } +type ResponseStatus struct { + Response string `json:"response,omitempty"` + // there's an additional field called `time` which returns date and time when the response was returned + // but we don't need that +} + type Body struct { ContentType string `json:"contentType,omitempty"` Content string `json:"content,omitempty"` diff --git a/internal/config/config.go b/internal/config/config.go index 2efccb4..8c46529 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,11 +8,11 @@ import ( ) type File struct { - Path string - Auth AuthStorage - Source Source `yaml:"source"` - Sink Sink `yaml:"sink"` - // TODO: filters for source events + Path string + Auth AuthStorage + Source Source `yaml:"source"` + Sink Sink `yaml:"sink"` + Filters []Filter `yaml:"filters,omitempty"` Transformations []Transformer `yaml:"transformations,omitempty"` Sync Sync `yaml:"sync"` UpdateConcurrency int `yaml:"updateConcurrency,omitempty"` @@ -77,6 +77,13 @@ type Transformer struct { Config CustomMap `yaml:"config"` } +type Filter struct { + // Name of the filter + Name string `yaml:"name"` + // Any kind of parameter which can be passed to a filter. + Config CustomMap `yaml:"config"` +} + // Sync configuration type Sync struct { StartTime SyncTime `yaml:"start"` diff --git a/internal/filter/allDayEvents.go b/internal/filter/allDayEvents.go new file mode 100644 index 0000000..119bb68 --- /dev/null +++ b/internal/filter/allDayEvents.go @@ -0,0 +1,16 @@ +package filter + +import ( + "github.com/inovex/CalendarSync/internal/models" +) + +type AllDayEvents struct { +} + +func (a AllDayEvents) Name() string { + return "AllDayEvents" +} + +func (a AllDayEvents) Filter(event models.Event) bool { + return !event.AllDay +} diff --git a/internal/filter/declinedEvents.go b/internal/filter/declinedEvents.go new file mode 100644 index 0000000..8bff962 --- /dev/null +++ b/internal/filter/declinedEvents.go @@ -0,0 +1,15 @@ +package filter + +import ( + "github.com/inovex/CalendarSync/internal/models" +) + +type DeclinedEvents struct{} + +func (d DeclinedEvents) Name() string { + return "DeclinedEvents" +} + +func (d DeclinedEvents) Filter(event models.Event) bool { + return event.Accepted +} diff --git a/internal/models/event.go b/internal/models/event.go index db0e3d2..7713c2f 100644 --- a/internal/models/event.go +++ b/internal/models/event.go @@ -27,6 +27,7 @@ type Event struct { Attendees Attendees Reminders Reminders MeetingLink string + Accepted bool } type Reminders []Reminder diff --git a/internal/sync/controller.go b/internal/sync/controller.go index f9a87bc..e95f45e 100644 --- a/internal/sync/controller.go +++ b/internal/sync/controller.go @@ -21,17 +21,19 @@ type Controller struct { source Source // transformers are applied in order transformers []Transformer + filters []Filter sink Sink concurrency int logger *log.Logger } // NewController constructs a new Controller. -func NewController(logger *log.Logger, source Source, sink Sink, transformer ...Transformer) Controller { +func NewController(logger *log.Logger, source Source, sink Sink, transformer []Transformer, filters []Filter) Controller { return Controller{ concurrency: 1, source: source, transformers: transformer, + filters: filters, sink: sink, logger: logger, } @@ -75,6 +77,20 @@ func (p Controller) SynchroniseTimeframe(ctx context.Context, start time.Time, e return err } + filteredEventsInSource := []models.Event{} + + for _, filter := range p.filters { + p.logger.Info("loaded filter", "name", filter.Name()) + } + + for _, event := range eventsInSource { + if FilterEvent(event, p.filters...) { + filteredEventsInSource = append(filteredEventsInSource, event) + } else { + p.logger.Debug("filter rejects event", logFields(event)...) + } + } + // Transform source events before comparing them to the sink events transformedEventsInSource := []models.Event{} @@ -83,7 +99,7 @@ func (p Controller) SynchroniseTimeframe(ctx context.Context, start time.Time, e p.logger.Info("loaded transformer", "name", trans.Name()) } - for _, event := range eventsInSource { + for _, event := range filteredEventsInSource { transformedEventsInSource = append(transformedEventsInSource, TransformEvent(event, p.transformers...)) } diff --git a/internal/sync/controller_test.go b/internal/sync/controller_test.go index cade14c..f1a8f27 100644 --- a/internal/sync/controller_test.go +++ b/internal/sync/controller_test.go @@ -34,7 +34,10 @@ func (suite *ControllerTestSuite) SetupTest() { {Name: "KeepTitle"}, {Name: "KeepReminders"}, }) - suite.controller = NewController(log.Default(), suite.source, suite.sink, transformers...) + filters := FilterFactory([]config.Filter{ + {Name: "DeclinedEvents"}, + }) + suite.controller = NewController(log.Default(), suite.source, suite.sink, transformers, filters) } // TestDryRun tests that no acutal adapter func is called @@ -54,6 +57,7 @@ func (suite *ControllerTestSuite) TestDryRun() { AllDay: false, Metadata: models.NewEventMetadata("seed1", "uri", "sourceID"), Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: startTime.Add(-10 * time.Minute)}}}, + Accepted: true, }, { ICalUID: "testID3", @@ -65,6 +69,7 @@ func (suite *ControllerTestSuite) TestDryRun() { AllDay: false, Metadata: models.NewEventMetadata("seed3", "uri", "sourceID"), Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: startTime.Add(-10 * time.Minute)}}}, + Accepted: true, }, } @@ -197,6 +202,7 @@ func (suite *ControllerTestSuite) TestCreateEventsEmptySink() { AllDay: false, Metadata: models.NewEventMetadata("seed1", "uri", "sourceID"), Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: time.Now().Add(-10 * time.Minute)}}}, + Accepted: true, }, { ICalUID: "testID2", @@ -207,6 +213,7 @@ func (suite *ControllerTestSuite) TestCreateEventsEmptySink() { EndTime: time.Now().Add(time.Hour), AllDay: false, Metadata: models.NewEventMetadata("seed2", "uri", "sourceID"), + Accepted: true, }, } @@ -241,6 +248,7 @@ func (suite *ControllerTestSuite) TestDeleteEventsNotInSink() { AllDay: false, Metadata: models.NewEventMetadata("seed1", "uri", "sourceID"), Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: startTime.Add(-10 * time.Minute)}}}, + Accepted: true, }, } sinkEvents := []models.Event{ @@ -325,6 +333,7 @@ func (suite *ControllerTestSuite) TestUpdateEventsPrefilledSink() { AllDay: false, Metadata: models.NewEventMetadata("seed1", "uri", "sourceID"), Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: time.Now().Add(-10 * time.Minute)}}}, + Accepted: true, }, { ICalUID: "testID2", @@ -335,6 +344,7 @@ func (suite *ControllerTestSuite) TestUpdateEventsPrefilledSink() { EndTime: endTime, AllDay: false, Metadata: models.NewEventMetadata("seed2", "uri", "sourceID"), + Accepted: true, }, { ICalUID: "testID3", @@ -345,6 +355,7 @@ func (suite *ControllerTestSuite) TestUpdateEventsPrefilledSink() { EndTime: endTime, AllDay: false, Metadata: models.NewEventMetadata("seed3", "uri", "sourceID"), + Accepted: true, }, { ICalUID: "testID4", @@ -356,6 +367,7 @@ func (suite *ControllerTestSuite) TestUpdateEventsPrefilledSink() { AllDay: false, Metadata: models.NewEventMetadata("seed4", "uri", "sourceID"), Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: time.Now().Add(-30 * time.Minute)}}}, + Accepted: true, }, } @@ -437,6 +449,51 @@ func (suite *ControllerTestSuite) TestUpdateEventsPrefilledSink() { suite.sink.AssertNotCalled(suite.T(), "DeleteEvent", ctx, mock.AnythingOfType("models.Event")) } +// TestCreateEventsDeclined asserts that, only the accepted event gets synced +func (suite *ControllerTestSuite) TestCreateEventsDeclined() { + ctx := context.Background() + startTime := time.Now() + endTime := startTime.Add(2 * time.Hour) + eventsToCreate := []models.Event{ + { + ICalUID: "testID", + ID: "testUID", + Title: "Title", + Description: "Description", + StartTime: time.Now(), + EndTime: time.Now().Add(time.Hour), + AllDay: false, + Metadata: models.NewEventMetadata("seed1", "uri", "sourceID"), + Reminders: []models.Reminder{{Actions: models.ReminderActionDisplay, Trigger: models.ReminderTrigger{PointInTime: time.Now().Add(-10 * time.Minute)}}}, + Accepted: true, + }, + { + ICalUID: "testID2", + ID: "testUID2", + Title: "Title", + Description: "Description", + StartTime: time.Now(), + EndTime: time.Now().Add(time.Hour), + AllDay: false, + Metadata: models.NewEventMetadata("seed2", "uri", "sourceID"), + Accepted: false, + }, + } + + suite.source.On("EventsInTimeframe", ctx, startTime, endTime).Return(eventsToCreate, nil) + suite.sink.On("EventsInTimeframe", ctx, startTime, endTime).Return(nil, nil) + suite.sink.On("CreateEvent", ctx, mock.AnythingOfType("models.Event")).Return(nil) + + err := suite.controller.SynchroniseTimeframe(ctx, startTime, endTime, false) + assert.NoError(suite.T(), err) + + suite.source.AssertCalled(suite.T(), "EventsInTimeframe", ctx, startTime, endTime) + suite.sink.AssertCalled(suite.T(), "EventsInTimeframe", ctx, startTime, endTime) + suite.sink.AssertNumberOfCalls(suite.T(), "CreateEvent", len(eventsToCreate)-1) + suite.sink.AssertNotCalled(suite.T(), "UpdateEvent", ctx, mock.AnythingOfType("models.Event")) + suite.sink.AssertNotCalled(suite.T(), "DeleteEvent", ctx, mock.AnythingOfType("models.Event")) +} + func TestControllerTestSuite(t *testing.T) { suite.Run(t, new(ControllerTestSuite)) } diff --git a/internal/sync/filter.go b/internal/sync/filter.go new file mode 100644 index 0000000..7004e9a --- /dev/null +++ b/internal/sync/filter.go @@ -0,0 +1,68 @@ +package sync + +import ( + "fmt" + "strings" + + "github.com/inovex/CalendarSync/internal/config" + "github.com/inovex/CalendarSync/internal/filter" + "github.com/inovex/CalendarSync/internal/models" +) + +type Filter interface { + NamedComponent + // Filter returns true to keep the event + Filter(event models.Event) bool +} + +// FilterEvent returns false if one of the filters rejects the event +func FilterEvent(event models.Event, filters ...Filter) (result bool) { + for _, filter := range filters { + // If the filter returns false (or: filters the event), then return false + if !filter.Filter(event) { + return false + } + } + // otherwise keep the event + return true +} + +var ( + filterConfigMapping = map[string]Filter{ + "DeclinedEvents": &filter.DeclinedEvents{}, + "AllDayEvents": &filter.AllDayEvents{}, + } + + filterOrder = []string{ + "DeclinedEvents", + "AllDayEvents", + } +) + +func FilterFactory(configuredFilters []config.Filter) (loadedFilters []Filter) { + for _, configuredFilter := range configuredFilters { + if _, nameExists := filterConfigMapping[configuredFilter.Name]; !nameExists { + // todo: handle properly + panic(fmt.Sprintf("unknown filter: %s", configuredFilter.Name)) + } + // load the default Transformer for the configured name and initialize it based on the config + filterDefault := filterConfigMapping[configuredFilter.Name] + loadedFilters = append(loadedFilters, filterFromConfig(filterDefault, configuredFilter.Config)) + } + + var sortedAndLoadedFilter []Filter + for _, name := range filterOrder { + for _, v := range loadedFilters { + if strings.EqualFold(name, v.Name()) { + sortedAndLoadedFilter = append(sortedAndLoadedFilter, v) + } + } + } + + return sortedAndLoadedFilter +} + +func filterFromConfig(filter Filter, config config.CustomMap) Filter { + autoConfigure(filter, config) + return filter +} diff --git a/internal/sync/transformer.go b/internal/sync/transformer.go index e9c0c27..34eb567 100644 --- a/internal/sync/transformer.go +++ b/internal/sync/transformer.go @@ -2,7 +2,6 @@ package sync import ( "fmt" - "reflect" "strings" "github.com/inovex/CalendarSync/internal/config" @@ -79,34 +78,6 @@ func TransformerFactory(configuredTransformers []config.Transformer) (loadedTran return sortedAndLoadedTransformer } -// autoConfigure can automatically map keys of the config.CustomMap to fields of a given Transformer implementation. -// TODO: I kinda wrote this in a hurry. Re-visit this. -func autoConfigure(transformer Transformer, config config.CustomMap) { - ps := reflect.ValueOf(transformer) - s := ps.Elem() - if s.Kind() == reflect.Struct { - for key, value := range config { - field := s.FieldByName(key) - if field.IsValid() && field.CanSet() { - switch field.Kind() { - case reflect.Int, - reflect.Int8, - reflect.Int16, - reflect.Int32, - reflect.Int64: - field.SetInt(value.(int64)) - case reflect.Bool: - field.SetBool(value.(bool)) - case reflect.String: - field.SetString(value.(string)) - default: - panic(fmt.Sprintf("autoConfigure(): unknown kind '%s' for field '%s'", key, field.Kind().String())) - } - } - } - } -} - func TransformerFromConfig(transformer Transformer, config config.CustomMap) Transformer { autoConfigure(transformer, config) return transformer diff --git a/internal/sync/util.go b/internal/sync/util.go new file mode 100644 index 0000000..8512c55 --- /dev/null +++ b/internal/sync/util.go @@ -0,0 +1,34 @@ +package sync + +import ( + "fmt" + "reflect" + + "github.com/inovex/CalendarSync/internal/config" +) + +func autoConfigure(object any, config config.CustomMap) { + ps := reflect.ValueOf(object) + s := ps.Elem() + if s.Kind() == reflect.Struct { + for key, value := range config { + field := s.FieldByName(key) + if field.IsValid() && field.CanSet() { + switch field.Kind() { + case reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64: + field.SetInt(value.(int64)) + case reflect.Bool: + field.SetBool(value.(bool)) + case reflect.String: + field.SetString(value.(string)) + default: + panic(fmt.Sprintf("autoConfigure(): unknown kind '%s' for field '%s'", key, field.Kind().String())) + } + } + } + } +} diff --git a/testdata/testconfig.yaml b/testdata/testconfig.yaml index 14f7a6b..ea9a279 100644 --- a/testdata/testconfig.yaml +++ b/testdata/testconfig.yaml @@ -1,3 +1,4 @@ +--- sync: start: identifier: MonthStart # 1st of the current month