Skip to content

Commit

Permalink
Add Attendees, FreeBusy and Organizers to event model
Browse files Browse the repository at this point in the history
  • Loading branch information
bengelhaupt committed Nov 21, 2020
1 parent 01b5388 commit d485d79
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 13 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +20,22 @@ class CalSyncEvent {
Integer reminderMinutesBeforeStart
String body
Boolean isAllDayEvent
List<Attendee> 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 115 additions & 7 deletions src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand All @@ -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"
)
}

Expand All @@ -121,25 +148,68 @@ class Mapper {
return event.getStart().getDate() && event.getEnd().getDate()
}

static List<ExchangeEvent.Attendee> 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.
*
* @param exchangeEvent Exchange Event
* @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
)
}

Expand All @@ -164,14 +234,46 @@ 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),
end: toGoogleEventDateTime(calSyncEvent.isAllDayEvent, calSyncEvent.endDateTime),
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
)
)
}

Expand All @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ class UserConfig {
Integer nextSyncInMinutes
Boolean includeCanceledEvents
Boolean includeEventBody
Boolean includeEventAttendees
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -99,7 +101,8 @@ class UserConfigReader {
totalSyncDays: totalSyncDays,
nextSyncInMinutes: nextSyncInMinutes,
includeCanceledEvents: includeCanceledEvents,
includeEventBody: includeEventBody
includeEventBody: includeEventBody,
includeEventAttendees: includeEventAttendees
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,20 @@ class ExchangeEvent {
DateTime endDateTime
String subject
String location
Boolean isReminderSet
Integer reminderMinutesBeforeStart
String body
Boolean isCanceled
Boolean isAllDayEvent
List<Attendee> optionalAttendees
List<Attendee> requiredAttendees
String organizerAddress
String organizerName
Boolean isBusy

static class Attendee {
String address
String name
String response
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)}...")
Expand All @@ -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) }
}
}

8 changes: 7 additions & 1 deletion src/main/resources/calsync-sample.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
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

0 comments on commit d485d79

Please sign in to comment.