Skip to content

Commit

Permalink
Event filtering.
Browse files Browse the repository at this point in the history
  • Loading branch information
Choon-Chern Lim committed Dec 13, 2016
1 parent 68aac7c commit 632157f
Show file tree
Hide file tree
Showing 19 changed files with 452 additions and 121 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Change Log

## 0.2.0 - 2016-12-13

* Two new configuration options:-
* `include.canceled.events` = Whether to include canceled events or not.
* `include.event.body` = Whether to include event body or not.

* Better console log messages.

## 0.1.0 - 2016-12-11

* Initial.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,59 @@ calsync/
* Edit `calsync.conf`.

* Run `java -jar calsync.jar` again.


## calsync.conf

If `calsync.conf` is missing (ie: you are running it for the first time), the configuration file will be
generated for you.

```
# Environment variable name containing Exchange user name value.
#
# Accepted value: string.
exchange.username.env=CALSYNC_EXCHANGE_USERNAME
# Environment variable name containing Exchange password value.
#
# Accepted value: string.
exchange.password.env=CALSYNC_EXCHANGE_PASSWORD
# Exchange web service URL.
#
# Accepted value: string.
exchange.url=https://[EXCHANGE_SERVER]/ews/exchange.asmx
# File path to Google client_secret.json.
#
# Accepted value: string.
google.client.secret.json.file.path=client_secret.json
# A new Google calendar name that DOESN'T MATCH any existing Google calendar names.
# Because CalSync performs one-way sync from Exchange calendar to Google calendar, it will wipe
# any existing events in Google calendar if they don't match events from Exchange calendar.
#
# Accepted value: string.
google.calendar.name=Outlook
# Total days to sync events from current day.
#
# Accepted value: integer greater than 0.
total.sync.in.days=7
# Next sync in minutes, or 0 to disable next run.
#
# Accepted value: integer.
next.sync.in.minutes=15
# Whether to include events marked as "canceled" or not.
#
# Accepted value: true, false.
include.canceled.events=false
# Whether to include event body or not. When syncing from work Exchange calendar, sometimes it's
# 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
```
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
<artifactId>joda-time</artifactId>
<version>2.9.6</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.10.1</version>
</dependency>

<dependency>
<groupId>com.google.apis</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ class ExchangeToGoogleService {
googleService.init(userConfig)

// retrieve exchange events
List<CalSyncEvent> exchangeEvents = exchangeService.getEvents(startDateTime, endDateTime)
List<CalSyncEvent> exchangeEvents = exchangeService.getEvents(
startDateTime,
endDateTime,
userConfig.includeCanceledEvents,
userConfig.includeEventBody)

// retrieve google calendar
String calendarId = googleService.getCalendarId(userConfig.googleCalendarName)
Expand Down
97 changes: 87 additions & 10 deletions src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
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.EventDateTime
import com.google.api.services.calendar.model.EventReminder
import microsoft.exchange.webservices.data.core.service.item.Appointment
import microsoft.exchange.webservices.data.property.complex.MessageBody
import org.apache.commons.lang3.StringEscapeUtils
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatter
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.safety.Whitelist

/**
* Utility class to map one object type to another.
*/
class Mapper {
static final DateTimeFormatter dateTimeFormatter = DateTimeFormat.forPattern("MMM dd '@' hh:mm a")

/**
* Maps Google EventDateTime to Joda DateTime.
*
Expand Down Expand Up @@ -61,25 +71,29 @@ class Mapper {
endDateTime: toJodaDateTime(event.getEnd()),
subject: event.getSummary(),
location: event.getLocation(),
reminderMinutesBeforeStart: event.getReminders()?.getOverrides()?.get(0)?.getMinutes()
reminderMinutesBeforeStart: event.getReminders()?.getOverrides()?.get(0)?.getMinutes(),
body: event.getDescription() ?: null
)
}

/**
* Maps Exchange Event to CalSyncEvent.
*
* @param appointment Exchange Event
* @param exchangeEvent Exchange Event
* @param includeEventBody Whether to include event body or not
* @return CalSyncEvent
*/
static CalSyncEvent toCalSyncEvent(Appointment appointment) {
assert appointment
static CalSyncEvent toCalSyncEvent(ExchangeEvent exchangeEvent, Boolean includeEventBody) {
assert exchangeEvent
assert includeEventBody != null

return new CalSyncEvent(
startDateTime: new org.joda.time.DateTime(appointment.start),
endDateTime: new org.joda.time.DateTime(appointment.end),
subject: appointment.subject,
location: appointment.location,
reminderMinutesBeforeStart: appointment.reminderMinutesBeforeStart
startDateTime: exchangeEvent.startDateTime,
endDateTime: exchangeEvent.endDateTime,
subject: exchangeEvent.subject,
location: exchangeEvent.location,
reminderMinutesBeforeStart: exchangeEvent.reminderMinutesBeforeStart,
body: includeEventBody ? exchangeEvent.body : null
)
}

Expand Down Expand Up @@ -110,7 +124,70 @@ class Mapper {
end: toGoogleEventDateTime(calSyncEvent.endDateTime),
summary: calSyncEvent.subject,
location: calSyncEvent.location,
reminders: reminders
reminders: reminders,
description: calSyncEvent.body
)
}

/**
* Maps Appointment to ExchangeEvent.
*
* @param appointment Appointment
* @return ExchangeEvent
*/
// TODO not testable ATM, not sure how to mock Appointment to return `body` data
static ExchangeEvent toExchangeEvent(Appointment appointment) {
assert appointment

return new ExchangeEvent(
startDateTime: new org.joda.time.DateTime(appointment.start),
endDateTime: new org.joda.time.DateTime(appointment.end),
subject: appointment.subject,
location: appointment.location,
reminderMinutesBeforeStart: appointment.reminderMinutesBeforeStart,
body: toPlainText(MessageBody.getStringFromMessageBody(appointment.body)),
isCanceled: appointment.isCancelled
)
}

/**
* Transforms HTML text to plain text.
*
* @param html HTML text
* @return Plain text
*/
// http://stackoverflow.com/questions/5640334/how-do-i-preserve-line-breaks-when-using-jsoup-to-convert-html-to-plain-text
static String toPlainText(String html) {
if (!html?.trim()) {
return null
}

Document.OutputSettings outputSettings = new Document.OutputSettings().prettyPrint(false)

Document document = Jsoup.parse(html)
document.outputSettings(outputSettings)

document.select('br').append('\\n')
document.select('p').prepend('\\n\\n')

// Exchange tends to have <div>&nbsp;</div> as line separator
document.select('div:contains( )').prepend('\\n\\n')

String sanitizedHtml = document.html().replaceAll('\\\\n', '\n').replaceAll('&nbsp;', ' ')

return StringEscapeUtils.unescapeHtml4(Jsoup.clean(sanitizedHtml, '', Whitelist.none(), outputSettings)).
trim() ?: null
}

/**
* Returns human readable datetime.
*
* @param dateTime Joda time
* @return Datetime string
*/
static String humanReadableDateTime(org.joda.time.DateTime dateTime) {
assert dateTime

return dateTimeFormatter.print(dateTime)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ class UserConfig {
Integer totalSyncDays
Integer nextSyncInMinutes
Boolean includeCanceledEvents
Boolean includeEventBody
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import groovy.transform.PackageScope
*/
class UserConfigReader {

static final String SAMPLE_CONF = 'calsync-sample.conf'

static final String EXCHANGE_USERNAME_ENV_KEY = 'exchange.username.env'
static final String EXCHANGE_PASSWORD_ENV_KEY = 'exchange.password.env'
static final String EXCHANGE_URL_KEY = 'exchange.url'
static final String GOOGLE_CLIENT_SECRET_JSON_KEY = 'google.client.secret.json.file.path'
static final String GOOGLE_CALENDAR_NAME_KEY = 'google.calendar.name'
static final String TOTAL_SYNC_IN_DAYS_KEY = 'total.sync.in.days'
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'

/**
* Returns user config.
Expand Down Expand Up @@ -41,29 +45,7 @@ class UserConfigReader {
return props
}

propsFile.write('''
# Environment variable name containing Exchange user name value.
exchange.username.env=CALSYNC_EXCHANGE_USERNAME
# Environment variable name containing Exchange password value.
exchange.password.env=CALSYNC_EXCHANGE_PASSWORD
# Exchange web service URL.
exchange.url=https://[EXCHANGE_SERVER]/ews/exchange.asmx
# Google client_secret.json.
google.client.secret.json.file.path=client_secret.json
# Google calendar name. If calendar name matches existing Google calendar names, that will be used.
# Otherwise, a new calendar of that name will be created first.
google.calendar.name=Outlook
# Total days to sync events from current day.
total.sync.in.days=7
# Next sync in minutes, or 0 to disable next run.
next.sync.in.minutes=15
''')
propsFile.write(this.class.classLoader.getResource(SAMPLE_CONF).text)

throw new CalSyncException(
"${Constant.CONFIG_FILE_PATH} not found... creating one at ${propsFile.getAbsoluteFile()}. " +
Expand Down Expand Up @@ -96,6 +78,9 @@ next.sync.in.minutes=15

Integer nextSyncInMinutes = validatePropInteger(props, errors, NEXT_SYNC_IN_MINUTES_KEY)

Boolean includeCanceledEvents = validatePropBoolean(props, errors, INCLUDE_CANCELED_EVENTS_KEY)
Boolean includeEventBody = validatePropBoolean(props, errors, INCLUDE_EVENT_BODY_KEY)

if (!errors.isEmpty()) {
throw new CalSyncException(
"The configuration is invalid. Please fix the errors below, then run it again:-" +
Expand All @@ -109,7 +94,9 @@ next.sync.in.minutes=15
googleClientSecretJsonFilePath: googleClientSecretJsonFilePath,
googleCalendarName: googleCalendarName,
totalSyncDays: totalSyncDays,
nextSyncInMinutes: nextSyncInMinutes
nextSyncInMinutes: nextSyncInMinutes,
includeCanceledEvents: includeCanceledEvents,
includeEventBody: includeEventBody
)
}

Expand Down Expand Up @@ -161,6 +148,29 @@ next.sync.in.minutes=15
return value.toInteger()
}

/**
* Ensures property has boolean value.
*
* @param props Properties
* @param errors Error list
* @param propKey Property key
* @return Boolean value if valid, otherwise null
*/
private Boolean validatePropBoolean(Properties props, List<String> errors, String propKey) {
String value = validatePropString(props, errors, propKey)

if (!value) {
return null
}

if (!value.toLowerCase().matches(/true|false/)) {
errors.add("${propKey}: Must be true or false.")
return null
}

return Boolean.valueOf(value)
}

/**
* Ensures property has string value.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.github.choonchernlim.calsync.exchange

import com.github.choonchernlim.calsync.core.Mapper
import com.github.choonchernlim.calsync.core.UserConfig
import groovy.transform.PackageScope
import microsoft.exchange.webservices.data.core.ExchangeService
import microsoft.exchange.webservices.data.core.PropertySet
import microsoft.exchange.webservices.data.core.enumeration.property.WellKnownFolderName
import microsoft.exchange.webservices.data.core.service.folder.CalendarFolder
import microsoft.exchange.webservices.data.core.service.item.Appointment
import microsoft.exchange.webservices.data.credential.WebCredentials
import microsoft.exchange.webservices.data.search.CalendarView
import org.joda.time.DateTime
Expand All @@ -28,8 +29,6 @@ class ExchangeClient {
void init(UserConfig userConfig) {
assert userConfig

LOGGER.info('Authenticating against Exchange...')

this.service.setCredentials(new WebCredentials(userConfig.exchangeUserName, userConfig.exchangePassword))
this.service.setUrl(new URI(userConfig.exchangeUrl))
}
Expand All @@ -41,12 +40,18 @@ class ExchangeClient {
* @param endDateTime End datetime
* @return Events
*/
List<Appointment> getEvents(DateTime startDateTime, DateTime endDateTime) {
List<ExchangeEvent> getEvents(DateTime startDateTime, DateTime endDateTime) {
assert startDateTime != null && endDateTime != null && startDateTime <= endDateTime

return CalendarFolder.bind(service, WellKnownFolderName.Calendar).
findAppointments(new CalendarView(startDateTime.toDate(), endDateTime.toDate())).
getItems()
getItems()?.
collect { appointment ->
appointment.load(PropertySet.firstClassProperties)
Mapper.toExchangeEvent(appointment)
} ?: []
}


}

Loading

0 comments on commit 632157f

Please sign in to comment.