From d485d790db565a61ea7bb09d94c786cb9839827e Mon Sep 17 00:00:00 2001 From: bengelhaupt Date: Sat, 21 Nov 2020 19:41:48 +0100 Subject: [PATCH] Add Attendees, FreeBusy and Organizers to event model --- README.md | 6 + .../calsync/core/CalSyncEvent.groovy | 19 ++- .../core/ExchangeToGoogleService.groovy | 3 +- .../choonchernlim/calsync/core/Mapper.groovy | 122 +++++++++++++++++- .../calsync/core/UserConfig.groovy | 1 + .../calsync/core/UserConfigReader.groovy | 5 +- .../calsync/exchange/ExchangeEvent.groovy | 12 ++ .../calsync/exchange/ExchangeService.groovy | 6 +- src/main/resources/calsync-sample.conf | 8 +- 9 files changed, 169 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bec1b3d..2ff8d86 100644 --- a/README.md +++ b/README.md @@ -116,4 +116,10 @@ include.canceled.events=false # # Accepted value: true, false. include.event.body=false + +# Whether to include event attendees or not. When syncing from work Exchange calendar, sometimes it's +# safer NOT to copy the attendees, which may include sensitive information, or due to work policy. +# +# Accepted value: true, false. +include.event.attendees=false ``` diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/CalSyncEvent.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/CalSyncEvent.groovy index ed6902a..f01dc1f 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/CalSyncEvent.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/CalSyncEvent.groovy @@ -8,8 +8,9 @@ import org.joda.time.DateTime * Properties that this app cares across different calendar apps. * * Equals/HashCode allows a quick diff between calendar events. + * organizerAddress and organizerName are ignored, because Google might change the organizer to the current calendar */ -@EqualsAndHashCode(excludes = ['googleEventId', 'reminderMinutesBeforeStart']) +@EqualsAndHashCode(excludes = ['googleEventId', 'organizerAddress', 'organizerName']) @ToString(includeNames = true) class CalSyncEvent { DateTime startDateTime @@ -19,6 +20,22 @@ class CalSyncEvent { Integer reminderMinutesBeforeStart String body Boolean isAllDayEvent + List attendees + String organizerAddress + String organizerName + Boolean isBusy String googleEventId + + @EqualsAndHashCode + static class Attendee { + String address + String name + Response response + Boolean isOptional + + static enum Response { + ACCEPTED, DECLINED, TENTATIVE, NO_RESPONSE + } + } } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy index 4fe68e2..3637960 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy @@ -46,7 +46,8 @@ class ExchangeToGoogleService { startDateTime, endDateTime, userConfig.includeCanceledEvents, - userConfig.includeEventBody) + userConfig.includeEventBody, + userConfig.includeEventAttendees) } catch (ServiceRequestException e) { // on connection exception, suppress exception if user says so diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy index 2edc9ee..b327813 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy @@ -3,10 +3,13 @@ package com.github.choonchernlim.calsync.core import com.github.choonchernlim.calsync.exchange.ExchangeEvent import com.google.api.client.util.DateTime import com.google.api.services.calendar.model.Event +import com.google.api.services.calendar.model.EventAttendee import com.google.api.services.calendar.model.EventDateTime import com.google.api.services.calendar.model.EventReminder +import microsoft.exchange.webservices.data.core.enumeration.property.LegacyFreeBusyStatus import microsoft.exchange.webservices.data.core.enumeration.property.MeetingResponseType import microsoft.exchange.webservices.data.core.service.item.Appointment +import microsoft.exchange.webservices.data.property.complex.AttendeeCollection import microsoft.exchange.webservices.data.property.complex.MessageBody import org.apache.commons.lang3.StringEscapeUtils import org.joda.time.format.DateTimeFormat @@ -97,6 +100,26 @@ class Mapper { static CalSyncEvent toCalSyncEvent(Event event) { assert event + def attendees = event.getAttendees() + .collect { + def response = CalSyncEvent.Attendee.Response.NO_RESPONSE + if (it.getResponseStatus() == "accepted") { + response = CalSyncEvent.Attendee.Response.ACCEPTED + } else if (it.getResponseStatus() == "declined") { + response = CalSyncEvent.Attendee.Response.DECLINED + } else if (it.getResponseStatus() == "tentative") { + response = CalSyncEvent.Attendee.Response.TENTATIVE + } + + new CalSyncEvent.Attendee( + address: it.getEmail().toLowerCase(), // address might be lowercased by Google + name: it.getDisplayName(), + response: response, + isOptional: it.isOptional() + ) + } + .sort { it.address } // sort because Google might return attendees in a different order + return new CalSyncEvent( googleEventId: event.getId(), startDateTime: toJodaDateTime(event.getStart()), @@ -105,7 +128,11 @@ class Mapper { location: event.getLocation(), reminderMinutesBeforeStart: event.getReminders()?.getOverrides()?.get(0)?.getMinutes(), body: event.getDescription() ?: null, - isAllDayEvent: isAllDayEvent(event) + isAllDayEvent: isAllDayEvent(event), + attendees: attendees, + organizerAddress: event.getOrganizer().getEmail().toLowerCase(), // address might be lowercased by Google + organizerName: event.getOrganizer().getDisplayName(), + isBusy: event.getTransparency() != "transparent" ) } @@ -121,6 +148,38 @@ class Mapper { return event.getStart().getDate() && event.getEnd().getDate() } + static List toExchangeAttendeeList(AttendeeCollection attendeeCollection) { + assert attendeeCollection + + return attendeeCollection.collect { + new ExchangeEvent.Attendee( + address: it.address, + name: it.name, + response: it.responseType.name() + ) + } + } + + static CalSyncEvent.Attendee toCalSyncAttendee(ExchangeEvent.Attendee attendee, Boolean optional) { + assert attendee + + def response = CalSyncEvent.Attendee.Response.NO_RESPONSE + if (attendee.response == MeetingResponseType.Accept.name()) { + response = CalSyncEvent.Attendee.Response.ACCEPTED + } else if (attendee.response == MeetingResponseType.Decline.name()) { + response = CalSyncEvent.Attendee.Response.DECLINED + } else if (attendee.response == MeetingResponseType.Tentative.name()) { + response = CalSyncEvent.Attendee.Response.TENTATIVE + } + + new CalSyncEvent.Attendee( + address: attendee.address.toLowerCase(), // address might be lowercased by Google + name: attendee.name, + response: response, + isOptional: optional + ) + } + /** * Maps Exchange Event to CalSyncEvent. * @@ -128,18 +187,29 @@ class Mapper { * @param includeEventBody Whether to include event body or not * @return CalSyncEvent */ - static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody) { + static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody, Boolean includeAttendees) { assert exchangeEvent assert includeEventBody != null + assert includeAttendees != null + + def attendees = includeAttendees ? exchangeEvent.requiredAttendees.collect { + toCalSyncAttendee(it, false) + } + exchangeEvent.optionalAttendees.collect { + toCalSyncAttendee(it, true) + }.sort { it.address } : null // sort because Google might return attendees in a different order return new CalSyncEvent( startDateTime: exchangeEvent.startDateTime, endDateTime: exchangeEvent.endDateTime, subject: exchangeEvent.subject, location: exchangeEvent.location, - reminderMinutesBeforeStart: exchangeEvent.reminderMinutesBeforeStart, + reminderMinutesBeforeStart: exchangeEvent.isReminderSet ? exchangeEvent.reminderMinutesBeforeStart : null, body: includeEventBody ? exchangeEvent.body : null, - isAllDayEvent: exchangeEvent.isAllDayEvent + isAllDayEvent: exchangeEvent.isAllDayEvent, + attendees: attendees, + organizerAddress: exchangeEvent.organizerAddress.toLowerCase(), // address might be lowercased by Google + organizerName: exchangeEvent.organizerName, + isBusy: exchangeEvent.isBusy ) } @@ -164,6 +234,32 @@ class Mapper { ] ) : null + def attendees = calSyncEvent.attendees.collect { + def isOrganizer = it.address == calSyncEvent.organizerAddress + + def response = "needsAction" + if (it.response == CalSyncEvent.Attendee.Response.ACCEPTED) { + response = "accepted" + } else if (it.response == CalSyncEvent.Attendee.Response.DECLINED) { + response = "declined" + } else if (it.response == CalSyncEvent.Attendee.Response.TENTATIVE) { + response = "tentative" + } + + def name = it.name + if (isOrganizer) { + name = "$name (Organizer)" + } + + new EventAttendee( + email: it.address, + displayName: name, + responseStatus: response, + optional: it.isOptional, + organizer: isOrganizer + ) + } + return new Event( id: calSyncEvent.googleEventId, start: toGoogleEventDateTime(calSyncEvent.isAllDayEvent, calSyncEvent.startDateTime), @@ -171,7 +267,13 @@ class Mapper { summary: calSyncEvent.subject, location: calSyncEvent.location, reminders: reminders, - description: calSyncEvent.body + description: calSyncEvent.body, + attendees: attendees, + transparency: calSyncEvent.isBusy ? "opaque" : "transparent", + organizer: new Event.Organizer( + email: calSyncEvent.organizerAddress, + displayName: calSyncEvent.organizerName + ) ) } @@ -188,12 +290,18 @@ class Mapper { return new ExchangeEvent( startDateTime: new org.joda.time.DateTime(appointment.start), endDateTime: new org.joda.time.DateTime(appointment.end), - subject: "${MY_RESPONSE_TYPE[appointment.myResponseType]} - ${appointment.subject}", + subject: appointment.subject, location: appointment.location, + isReminderSet: appointment.isReminderSet, reminderMinutesBeforeStart: appointment.reminderMinutesBeforeStart, body: toPlainText(MessageBody.getStringFromMessageBody(appointment.body)), isCanceled: appointment.isCancelled, - isAllDayEvent: appointment.isAllDayEvent + isAllDayEvent: appointment.isAllDayEvent, + optionalAttendees: toExchangeAttendeeList(appointment.optionalAttendees), + requiredAttendees: toExchangeAttendeeList(appointment.requiredAttendees), + organizerAddress: appointment.organizer.address, + organizerName: appointment.organizer.name, + isBusy: appointment.legacyFreeBusyStatus == LegacyFreeBusyStatus.Busy ) } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy index d6c9ef9..9af2b6f 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy @@ -17,4 +17,5 @@ class UserConfig { Integer nextSyncInMinutes Boolean includeCanceledEvents Boolean includeEventBody + Boolean includeEventAttendees } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy index 797bc54..fca2ad0 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy @@ -19,6 +19,7 @@ class UserConfigReader { static final String NEXT_SYNC_IN_MINUTES_KEY = 'next.sync.in.minutes' static final String INCLUDE_CANCELED_EVENTS_KEY = 'include.canceled.events' static final String INCLUDE_EVENT_BODY_KEY = 'include.event.body' + static final String INCLUDE_EVENT_ATTENDEES_KEY = 'include.event.attendees' /** * Returns user config. @@ -82,6 +83,7 @@ class UserConfigReader { Boolean includeCanceledEvents = validatePropBoolean(props, errors, INCLUDE_CANCELED_EVENTS_KEY) Boolean includeEventBody = validatePropBoolean(props, errors, INCLUDE_EVENT_BODY_KEY) + Boolean includeEventAttendees = validatePropBoolean(props, errors, INCLUDE_EVENT_ATTENDEES_KEY) if (!errors.isEmpty()) { throw new CalSyncException( @@ -99,7 +101,8 @@ class UserConfigReader { totalSyncDays: totalSyncDays, nextSyncInMinutes: nextSyncInMinutes, includeCanceledEvents: includeCanceledEvents, - includeEventBody: includeEventBody + includeEventBody: includeEventBody, + includeEventAttendees: includeEventAttendees ) } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy index cd18db8..825b9f6 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy @@ -9,8 +9,20 @@ class ExchangeEvent { DateTime endDateTime String subject String location + Boolean isReminderSet Integer reminderMinutesBeforeStart String body Boolean isCanceled Boolean isAllDayEvent + List optionalAttendees + List requiredAttendees + String organizerAddress + String organizerName + Boolean isBusy + + static class Attendee { + String address + String name + String response + } } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy index 6882fb1..2fdc5fb 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy @@ -40,10 +40,12 @@ class ExchangeService { DateTime startDateTime, DateTime endDateTime, Boolean includeCanceledEvents, - Boolean includeEventBody) { + Boolean includeEventBody, + Boolean includeAttendees) { assert startDateTime && endDateTime && startDateTime <= endDateTime assert includeCanceledEvents != null assert includeEventBody != null + assert includeAttendees != null LOGGER.info( "Retrieving events from ${Mapper.humanReadableDateTime(startDateTime)} to ${Mapper.humanReadableDateTime(endDateTime)}...") @@ -57,7 +59,7 @@ class ExchangeService { LOGGER.info("\tTotal events after excluding canceled events: ${exchangeEvents.size()}...") } - return exchangeEvents.collect { Mapper.toCalSyncEvent(it, includeEventBody) } + return exchangeEvents.collect { Mapper.toCalSyncEvent(it, includeEventBody, includeAttendees) } } } diff --git a/src/main/resources/calsync-sample.conf b/src/main/resources/calsync-sample.conf index 1c84dbf..670e425 100644 --- a/src/main/resources/calsync-sample.conf +++ b/src/main/resources/calsync-sample.conf @@ -58,4 +58,10 @@ include.canceled.events=false # safer NOT to copy the event body, which may include sensitive information, or due to work policy. # # Accepted value: true, false. -include.event.body=false \ No newline at end of file +include.event.body=false + +# Whether to include event attendees or not. When syncing from work Exchange calendar, sometimes it's +# safer NOT to copy the attendees, which may include sensitive information, or due to work policy. +# +# Accepted value: true, false. +include.event.attendees=false \ No newline at end of file