Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
131 changes: 115 additions & 16 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 All @@ -21,15 +24,6 @@ import org.jsoup.safety.Whitelist
class Mapper {
static final DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern("MMM dd '@' hh:mm a")

private static final Map<MeetingResponseType, String> MY_RESPONSE_TYPE = [
(MeetingResponseType.Accept) : 'ACCEPTED',
(MeetingResponseType.Decline) : 'DECLINED',
(MeetingResponseType.NoResponseReceived): 'UNRESPONDED',
(MeetingResponseType.Tentative) : 'TENTATIVE',
(MeetingResponseType.Organizer) : 'ORGANIZER',
(MeetingResponseType.Unknown) : 'UNKNOWN',
]

/**
* Maps Google EventDateTime to Joda DateTime.
*
Expand Down Expand Up @@ -97,6 +91,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 +119,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 +139,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 } : [] // 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 +225,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 +281,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