Skip to content

Commit 082f9c6

Browse files
committed
Add Attendees, FreeBusy and Organizers to event model
1 parent 01b5388 commit 082f9c6

File tree

9 files changed

+169
-22
lines changed

9 files changed

+169
-22
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,10 @@ include.canceled.events=false
116116
#
117117
# Accepted value: true, false.
118118
include.event.body=false
119+
120+
# Whether to include event attendees or not. When syncing from work Exchange calendar, sometimes it's
121+
# safer NOT to copy the attendees, which may include sensitive information, or due to work policy.
122+
#
123+
# Accepted value: true, false.
124+
include.event.attendees=false
119125
```

src/main/groovy/com/github/choonchernlim/calsync/core/CalSyncEvent.groovy

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import org.joda.time.DateTime
88
* Properties that this app cares across different calendar apps.
99
*
1010
* Equals/HashCode allows a quick diff between calendar events.
11+
* organizerAddress and organizerName are ignored, because Google might change the organizer to the current calendar
1112
*/
12-
@EqualsAndHashCode(excludes = ['googleEventId', 'reminderMinutesBeforeStart'])
13+
@EqualsAndHashCode(excludes = ['googleEventId', 'organizerAddress', 'organizerName'])
1314
@ToString(includeNames = true)
1415
class CalSyncEvent {
1516
DateTime startDateTime
@@ -19,6 +20,22 @@ class CalSyncEvent {
1920
Integer reminderMinutesBeforeStart
2021
String body
2122
Boolean isAllDayEvent
23+
List<Attendee> attendees
24+
String organizerAddress
25+
String organizerName
26+
Boolean isBusy
2227

2328
String googleEventId
29+
30+
@EqualsAndHashCode
31+
static class Attendee {
32+
String address
33+
String name
34+
Response response
35+
Boolean isOptional
36+
37+
static enum Response {
38+
ACCEPTED, DECLINED, TENTATIVE, NO_RESPONSE
39+
}
40+
}
2441
}

src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ class ExchangeToGoogleService {
4646
startDateTime,
4747
endDateTime,
4848
userConfig.includeCanceledEvents,
49-
userConfig.includeEventBody)
49+
userConfig.includeEventBody,
50+
userConfig.includeEventAttendees)
5051
}
5152
catch (ServiceRequestException e) {
5253
// on connection exception, suppress exception if user says so

src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy

Lines changed: 115 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package com.github.choonchernlim.calsync.core
33
import com.github.choonchernlim.calsync.exchange.ExchangeEvent
44
import com.google.api.client.util.DateTime
55
import com.google.api.services.calendar.model.Event
6+
import com.google.api.services.calendar.model.EventAttendee
67
import com.google.api.services.calendar.model.EventDateTime
78
import com.google.api.services.calendar.model.EventReminder
9+
import microsoft.exchange.webservices.data.core.enumeration.property.LegacyFreeBusyStatus
810
import microsoft.exchange.webservices.data.core.enumeration.property.MeetingResponseType
911
import microsoft.exchange.webservices.data.core.service.item.Appointment
12+
import microsoft.exchange.webservices.data.property.complex.AttendeeCollection
1013
import microsoft.exchange.webservices.data.property.complex.MessageBody
1114
import org.apache.commons.lang3.StringEscapeUtils
1215
import org.joda.time.format.DateTimeFormat
@@ -21,15 +24,6 @@ import org.jsoup.safety.Whitelist
2124
class Mapper {
2225
static final DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern("MMM dd '@' hh:mm a")
2326

24-
private static final Map<MeetingResponseType, String> MY_RESPONSE_TYPE = [
25-
(MeetingResponseType.Accept) : 'ACCEPTED',
26-
(MeetingResponseType.Decline) : 'DECLINED',
27-
(MeetingResponseType.NoResponseReceived): 'UNRESPONDED',
28-
(MeetingResponseType.Tentative) : 'TENTATIVE',
29-
(MeetingResponseType.Organizer) : 'ORGANIZER',
30-
(MeetingResponseType.Unknown) : 'UNKNOWN',
31-
]
32-
3327
/**
3428
* Maps Google EventDateTime to Joda DateTime.
3529
*
@@ -97,6 +91,26 @@ class Mapper {
9791
static CalSyncEvent toCalSyncEvent(Event event) {
9892
assert event
9993

94+
def attendees = event.getAttendees()
95+
.collect {
96+
def response = CalSyncEvent.Attendee.Response.NO_RESPONSE
97+
if (it.getResponseStatus() == "accepted") {
98+
response = CalSyncEvent.Attendee.Response.ACCEPTED
99+
} else if (it.getResponseStatus() == "declined") {
100+
response = CalSyncEvent.Attendee.Response.DECLINED
101+
} else if (it.getResponseStatus() == "tentative") {
102+
response = CalSyncEvent.Attendee.Response.TENTATIVE
103+
}
104+
105+
new CalSyncEvent.Attendee(
106+
address: it.getEmail().toLowerCase(), // address might be lowercased by Google
107+
name: it.getDisplayName(),
108+
response: response,
109+
isOptional: it.isOptional()
110+
)
111+
}
112+
.sort { it.address } // sort because Google might return attendees in a different order
113+
100114
return new CalSyncEvent(
101115
googleEventId: event.getId(),
102116
startDateTime: toJodaDateTime(event.getStart()),
@@ -105,7 +119,11 @@ class Mapper {
105119
location: event.getLocation(),
106120
reminderMinutesBeforeStart: event.getReminders()?.getOverrides()?.get(0)?.getMinutes(),
107121
body: event.getDescription() ?: null,
108-
isAllDayEvent: isAllDayEvent(event)
122+
isAllDayEvent: isAllDayEvent(event),
123+
attendees: attendees,
124+
organizerAddress: event.getOrganizer().getEmail().toLowerCase(), // address might be lowercased by Google
125+
organizerName: event.getOrganizer().getDisplayName(),
126+
isBusy: event.getTransparency() != "transparent"
109127
)
110128
}
111129

@@ -121,25 +139,68 @@ class Mapper {
121139
return event.getStart().getDate() && event.getEnd().getDate()
122140
}
123141

142+
static List<ExchangeEvent.Attendee> toExchangeAttendeeList(AttendeeCollection attendeeCollection) {
143+
assert attendeeCollection
144+
145+
return attendeeCollection.collect {
146+
new ExchangeEvent.Attendee(
147+
address: it.address,
148+
name: it.name,
149+
response: it.responseType.name()
150+
)
151+
}
152+
}
153+
154+
static CalSyncEvent.Attendee toCalSyncAttendee(ExchangeEvent.Attendee attendee, Boolean optional) {
155+
assert attendee
156+
157+
def response = CalSyncEvent.Attendee.Response.NO_RESPONSE
158+
if (attendee.response == MeetingResponseType.Accept.name()) {
159+
response = CalSyncEvent.Attendee.Response.ACCEPTED
160+
} else if (attendee.response == MeetingResponseType.Decline.name()) {
161+
response = CalSyncEvent.Attendee.Response.DECLINED
162+
} else if (attendee.response == MeetingResponseType.Tentative.name()) {
163+
response = CalSyncEvent.Attendee.Response.TENTATIVE
164+
}
165+
166+
new CalSyncEvent.Attendee(
167+
address: attendee.address.toLowerCase(), // address might be lowercased by Google
168+
name: attendee.name,
169+
response: response,
170+
isOptional: optional
171+
)
172+
}
173+
124174
/**
125175
* Maps Exchange Event to CalSyncEvent.
126176
*
127177
* @param exchangeEvent Exchange Event
128178
* @param includeEventBody Whether to include event body or not
129179
* @return CalSyncEvent
130180
*/
131-
static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody) {
181+
static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody, Boolean includeAttendees) {
132182
assert exchangeEvent
133183
assert includeEventBody != null
184+
assert includeAttendees != null
185+
186+
def attendees = includeAttendees ? exchangeEvent.requiredAttendees.collect {
187+
toCalSyncAttendee(it, false)
188+
} + exchangeEvent.optionalAttendees.collect {
189+
toCalSyncAttendee(it, true)
190+
}.sort { it.address } : null // sort because Google might return attendees in a different order
134191

135192
return new CalSyncEvent(
136193
startDateTime: exchangeEvent.startDateTime,
137194
endDateTime: exchangeEvent.endDateTime,
138195
subject: exchangeEvent.subject,
139196
location: exchangeEvent.location,
140-
reminderMinutesBeforeStart: exchangeEvent.reminderMinutesBeforeStart,
197+
reminderMinutesBeforeStart: exchangeEvent.isReminderSet ? exchangeEvent.reminderMinutesBeforeStart : null,
141198
body: includeEventBody ? exchangeEvent.body : null,
142-
isAllDayEvent: exchangeEvent.isAllDayEvent
199+
isAllDayEvent: exchangeEvent.isAllDayEvent,
200+
attendees: attendees,
201+
organizerAddress: exchangeEvent.organizerAddress.toLowerCase(), // address might be lowercased by Google
202+
organizerName: exchangeEvent.organizerName,
203+
isBusy: exchangeEvent.isBusy
143204
)
144205
}
145206

@@ -164,14 +225,46 @@ class Mapper {
164225
]
165226
) : null
166227

228+
def attendees = calSyncEvent.attendees.collect {
229+
def isOrganizer = it.address == calSyncEvent.organizerAddress
230+
231+
def response = "needsAction"
232+
if (it.response == CalSyncEvent.Attendee.Response.ACCEPTED) {
233+
response = "accepted"
234+
} else if (it.response == CalSyncEvent.Attendee.Response.DECLINED) {
235+
response = "declined"
236+
} else if (it.response == CalSyncEvent.Attendee.Response.TENTATIVE) {
237+
response = "tentative"
238+
}
239+
240+
def name = it.name
241+
if (isOrganizer) {
242+
name = "$name (Organizer)"
243+
}
244+
245+
new EventAttendee(
246+
email: it.address,
247+
displayName: name,
248+
responseStatus: response,
249+
optional: it.isOptional,
250+
organizer: isOrganizer
251+
)
252+
}
253+
167254
return new Event(
168255
id: calSyncEvent.googleEventId,
169256
start: toGoogleEventDateTime(calSyncEvent.isAllDayEvent, calSyncEvent.startDateTime),
170257
end: toGoogleEventDateTime(calSyncEvent.isAllDayEvent, calSyncEvent.endDateTime),
171258
summary: calSyncEvent.subject,
172259
location: calSyncEvent.location,
173260
reminders: reminders,
174-
description: calSyncEvent.body
261+
description: calSyncEvent.body,
262+
attendees: attendees,
263+
transparency: calSyncEvent.isBusy ? "opaque" : "transparent",
264+
organizer: new Event.Organizer(
265+
email: calSyncEvent.organizerAddress,
266+
displayName: calSyncEvent.organizerName
267+
)
175268
)
176269
}
177270

@@ -188,12 +281,18 @@ class Mapper {
188281
return new ExchangeEvent(
189282
startDateTime: new org.joda.time.DateTime(appointment.start),
190283
endDateTime: new org.joda.time.DateTime(appointment.end),
191-
subject: "${MY_RESPONSE_TYPE[appointment.myResponseType]} - ${appointment.subject}",
284+
subject: appointment.subject,
192285
location: appointment.location,
286+
isReminderSet: appointment.isReminderSet,
193287
reminderMinutesBeforeStart: appointment.reminderMinutesBeforeStart,
194288
body: toPlainText(MessageBody.getStringFromMessageBody(appointment.body)),
195289
isCanceled: appointment.isCancelled,
196-
isAllDayEvent: appointment.isAllDayEvent
290+
isAllDayEvent: appointment.isAllDayEvent,
291+
optionalAttendees: toExchangeAttendeeList(appointment.optionalAttendees),
292+
requiredAttendees: toExchangeAttendeeList(appointment.requiredAttendees),
293+
organizerAddress: appointment.organizer.address,
294+
organizerName: appointment.organizer.name,
295+
isBusy: appointment.legacyFreeBusyStatus == LegacyFreeBusyStatus.Busy
197296
)
198297
}
199298

src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ class UserConfig {
1717
Integer nextSyncInMinutes
1818
Boolean includeCanceledEvents
1919
Boolean includeEventBody
20+
Boolean includeEventAttendees
2021
}

src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class UserConfigReader {
1919
static final String NEXT_SYNC_IN_MINUTES_KEY = 'next.sync.in.minutes'
2020
static final String INCLUDE_CANCELED_EVENTS_KEY = 'include.canceled.events'
2121
static final String INCLUDE_EVENT_BODY_KEY = 'include.event.body'
22+
static final String INCLUDE_EVENT_ATTENDEES_KEY = 'include.event.attendees'
2223

2324
/**
2425
* Returns user config.
@@ -82,6 +83,7 @@ class UserConfigReader {
8283

8384
Boolean includeCanceledEvents = validatePropBoolean(props, errors, INCLUDE_CANCELED_EVENTS_KEY)
8485
Boolean includeEventBody = validatePropBoolean(props, errors, INCLUDE_EVENT_BODY_KEY)
86+
Boolean includeEventAttendees = validatePropBoolean(props, errors, INCLUDE_EVENT_ATTENDEES_KEY)
8587

8688
if (!errors.isEmpty()) {
8789
throw new CalSyncException(
@@ -99,7 +101,8 @@ class UserConfigReader {
99101
totalSyncDays: totalSyncDays,
100102
nextSyncInMinutes: nextSyncInMinutes,
101103
includeCanceledEvents: includeCanceledEvents,
102-
includeEventBody: includeEventBody
104+
includeEventBody: includeEventBody,
105+
includeEventAttendees: includeEventAttendees
103106
)
104107
}
105108

src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,20 @@ class ExchangeEvent {
99
DateTime endDateTime
1010
String subject
1111
String location
12+
Boolean isReminderSet
1213
Integer reminderMinutesBeforeStart
1314
String body
1415
Boolean isCanceled
1516
Boolean isAllDayEvent
17+
List<Attendee> optionalAttendees
18+
List<Attendee> requiredAttendees
19+
String organizerAddress
20+
String organizerName
21+
Boolean isBusy
22+
23+
static class Attendee {
24+
String address
25+
String name
26+
String response
27+
}
1628
}

src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ class ExchangeService {
4040
DateTime startDateTime,
4141
DateTime endDateTime,
4242
Boolean includeCanceledEvents,
43-
Boolean includeEventBody) {
43+
Boolean includeEventBody,
44+
Boolean includeAttendees) {
4445
assert startDateTime && endDateTime && startDateTime <= endDateTime
4546
assert includeCanceledEvents != null
4647
assert includeEventBody != null
48+
assert includeAttendees != null
4749

4850
LOGGER.info(
4951
"Retrieving events from ${Mapper.humanReadableDateTime(startDateTime)} to ${Mapper.humanReadableDateTime(endDateTime)}...")
@@ -57,7 +59,7 @@ class ExchangeService {
5759
LOGGER.info("\tTotal events after excluding canceled events: ${exchangeEvents.size()}...")
5860
}
5961

60-
return exchangeEvents.collect { Mapper.toCalSyncEvent(it, includeEventBody) }
62+
return exchangeEvents.collect { Mapper.toCalSyncEvent(it, includeEventBody, includeAttendees) }
6163
}
6264
}
6365

src/main/resources/calsync-sample.conf

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,10 @@ include.canceled.events=false
5858
# safer NOT to copy the event body, which may include sensitive information, or due to work policy.
5959
#
6060
# Accepted value: true, false.
61-
include.event.body=false
61+
include.event.body=false
62+
63+
# Whether to include event attendees or not. When syncing from work Exchange calendar, sometimes it's
64+
# safer NOT to copy the attendees, which may include sensitive information, or due to work policy.
65+
#
66+
# Accepted value: true, false.
67+
include.event.attendees=false

0 commit comments

Comments
 (0)