Skip to content

Commit d485d79

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

File tree

9 files changed

+169
-13
lines changed

9 files changed

+169
-13
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 & 7 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
@@ -97,6 +100,26 @@ class Mapper {
97100
static CalSyncEvent toCalSyncEvent(Event event) {
98101
assert event
99102

103+
def attendees = event.getAttendees()
104+
.collect {
105+
def response = CalSyncEvent.Attendee.Response.NO_RESPONSE
106+
if (it.getResponseStatus() == "accepted") {
107+
response = CalSyncEvent.Attendee.Response.ACCEPTED
108+
} else if (it.getResponseStatus() == "declined") {
109+
response = CalSyncEvent.Attendee.Response.DECLINED
110+
} else if (it.getResponseStatus() == "tentative") {
111+
response = CalSyncEvent.Attendee.Response.TENTATIVE
112+
}
113+
114+
new CalSyncEvent.Attendee(
115+
address: it.getEmail().toLowerCase(), // address might be lowercased by Google
116+
name: it.getDisplayName(),
117+
response: response,
118+
isOptional: it.isOptional()
119+
)
120+
}
121+
.sort { it.address } // sort because Google might return attendees in a different order
122+
100123
return new CalSyncEvent(
101124
googleEventId: event.getId(),
102125
startDateTime: toJodaDateTime(event.getStart()),
@@ -105,7 +128,11 @@ class Mapper {
105128
location: event.getLocation(),
106129
reminderMinutesBeforeStart: event.getReminders()?.getOverrides()?.get(0)?.getMinutes(),
107130
body: event.getDescription() ?: null,
108-
isAllDayEvent: isAllDayEvent(event)
131+
isAllDayEvent: isAllDayEvent(event),
132+
attendees: attendees,
133+
organizerAddress: event.getOrganizer().getEmail().toLowerCase(), // address might be lowercased by Google
134+
organizerName: event.getOrganizer().getDisplayName(),
135+
isBusy: event.getTransparency() != "transparent"
109136
)
110137
}
111138

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

151+
static List<ExchangeEvent.Attendee> toExchangeAttendeeList(AttendeeCollection attendeeCollection) {
152+
assert attendeeCollection
153+
154+
return attendeeCollection.collect {
155+
new ExchangeEvent.Attendee(
156+
address: it.address,
157+
name: it.name,
158+
response: it.responseType.name()
159+
)
160+
}
161+
}
162+
163+
static CalSyncEvent.Attendee toCalSyncAttendee(ExchangeEvent.Attendee attendee, Boolean optional) {
164+
assert attendee
165+
166+
def response = CalSyncEvent.Attendee.Response.NO_RESPONSE
167+
if (attendee.response == MeetingResponseType.Accept.name()) {
168+
response = CalSyncEvent.Attendee.Response.ACCEPTED
169+
} else if (attendee.response == MeetingResponseType.Decline.name()) {
170+
response = CalSyncEvent.Attendee.Response.DECLINED
171+
} else if (attendee.response == MeetingResponseType.Tentative.name()) {
172+
response = CalSyncEvent.Attendee.Response.TENTATIVE
173+
}
174+
175+
new CalSyncEvent.Attendee(
176+
address: attendee.address.toLowerCase(), // address might be lowercased by Google
177+
name: attendee.name,
178+
response: response,
179+
isOptional: optional
180+
)
181+
}
182+
124183
/**
125184
* Maps Exchange Event to CalSyncEvent.
126185
*
127186
* @param exchangeEvent Exchange Event
128187
* @param includeEventBody Whether to include event body or not
129188
* @return CalSyncEvent
130189
*/
131-
static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody) {
190+
static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody, Boolean includeAttendees) {
132191
assert exchangeEvent
133192
assert includeEventBody != null
193+
assert includeAttendees != null
194+
195+
def attendees = includeAttendees ? exchangeEvent.requiredAttendees.collect {
196+
toCalSyncAttendee(it, false)
197+
} + exchangeEvent.optionalAttendees.collect {
198+
toCalSyncAttendee(it, true)
199+
}.sort { it.address } : null // sort because Google might return attendees in a different order
134200

135201
return new CalSyncEvent(
136202
startDateTime: exchangeEvent.startDateTime,
137203
endDateTime: exchangeEvent.endDateTime,
138204
subject: exchangeEvent.subject,
139205
location: exchangeEvent.location,
140-
reminderMinutesBeforeStart: exchangeEvent.reminderMinutesBeforeStart,
206+
reminderMinutesBeforeStart: exchangeEvent.isReminderSet ? exchangeEvent.reminderMinutesBeforeStart : null,
141207
body: includeEventBody ? exchangeEvent.body : null,
142-
isAllDayEvent: exchangeEvent.isAllDayEvent
208+
isAllDayEvent: exchangeEvent.isAllDayEvent,
209+
attendees: attendees,
210+
organizerAddress: exchangeEvent.organizerAddress.toLowerCase(), // address might be lowercased by Google
211+
organizerName: exchangeEvent.organizerName,
212+
isBusy: exchangeEvent.isBusy
143213
)
144214
}
145215

@@ -164,14 +234,46 @@ class Mapper {
164234
]
165235
) : null
166236

237+
def attendees = calSyncEvent.attendees.collect {
238+
def isOrganizer = it.address == calSyncEvent.organizerAddress
239+
240+
def response = "needsAction"
241+
if (it.response == CalSyncEvent.Attendee.Response.ACCEPTED) {
242+
response = "accepted"
243+
} else if (it.response == CalSyncEvent.Attendee.Response.DECLINED) {
244+
response = "declined"
245+
} else if (it.response == CalSyncEvent.Attendee.Response.TENTATIVE) {
246+
response = "tentative"
247+
}
248+
249+
def name = it.name
250+
if (isOrganizer) {
251+
name = "$name (Organizer)"
252+
}
253+
254+
new EventAttendee(
255+
email: it.address,
256+
displayName: name,
257+
responseStatus: response,
258+
optional: it.isOptional,
259+
organizer: isOrganizer
260+
)
261+
}
262+
167263
return new Event(
168264
id: calSyncEvent.googleEventId,
169265
start: toGoogleEventDateTime(calSyncEvent.isAllDayEvent, calSyncEvent.startDateTime),
170266
end: toGoogleEventDateTime(calSyncEvent.isAllDayEvent, calSyncEvent.endDateTime),
171267
summary: calSyncEvent.subject,
172268
location: calSyncEvent.location,
173269
reminders: reminders,
174-
description: calSyncEvent.body
270+
description: calSyncEvent.body,
271+
attendees: attendees,
272+
transparency: calSyncEvent.isBusy ? "opaque" : "transparent",
273+
organizer: new Event.Organizer(
274+
email: calSyncEvent.organizerAddress,
275+
displayName: calSyncEvent.organizerName
276+
)
175277
)
176278
}
177279

@@ -188,12 +290,18 @@ class Mapper {
188290
return new ExchangeEvent(
189291
startDateTime: new org.joda.time.DateTime(appointment.start),
190292
endDateTime: new org.joda.time.DateTime(appointment.end),
191-
subject: "${MY_RESPONSE_TYPE[appointment.myResponseType]} - ${appointment.subject}",
293+
subject: appointment.subject,
192294
location: appointment.location,
295+
isReminderSet: appointment.isReminderSet,
193296
reminderMinutesBeforeStart: appointment.reminderMinutesBeforeStart,
194297
body: toPlainText(MessageBody.getStringFromMessageBody(appointment.body)),
195298
isCanceled: appointment.isCancelled,
196-
isAllDayEvent: appointment.isAllDayEvent
299+
isAllDayEvent: appointment.isAllDayEvent,
300+
optionalAttendees: toExchangeAttendeeList(appointment.optionalAttendees),
301+
requiredAttendees: toExchangeAttendeeList(appointment.requiredAttendees),
302+
organizerAddress: appointment.organizer.address,
303+
organizerName: appointment.organizer.name,
304+
isBusy: appointment.legacyFreeBusyStatus == LegacyFreeBusyStatus.Busy
197305
)
198306
}
199307

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)