diff --git a/CHANGELOG.md b/CHANGELOG.md index cd21c88..2aed6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index b4364b5..bbc6766 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/pom.xml b/pom.xml index 406cef4..caa654d 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,11 @@ joda-time 2.9.6 + + org.jsoup + jsoup + 1.10.1 + com.google.apis diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy index a6a4453..9c2c858 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleService.groovy @@ -34,7 +34,11 @@ class ExchangeToGoogleService { googleService.init(userConfig) // retrieve exchange events - List exchangeEvents = exchangeService.getEvents(startDateTime, endDateTime) + List exchangeEvents = exchangeService.getEvents( + startDateTime, + endDateTime, + userConfig.includeCanceledEvents, + userConfig.includeEventBody) // retrieve google calendar String calendarId = googleService.getCalendarId(userConfig.googleCalendarName) diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy index 9bbc932..bcb7698 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/Mapper.groovy @@ -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. * @@ -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 ) } @@ -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
 
as line separator + document.select('div:contains( )').prepend('\\n\\n') + + String sanitizedHtml = document.html().replaceAll('\\\\n', '\n').replaceAll(' ', ' ') + + 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) + } } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy index 1b15106..0a770b1 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfig.groovy @@ -15,4 +15,5 @@ class UserConfig { Integer totalSyncDays Integer nextSyncInMinutes Boolean includeCanceledEvents + Boolean includeEventBody } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy index b861bda..30742eb 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/core/UserConfigReader.groovy @@ -7,6 +7,8 @@ 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' @@ -14,6 +16,8 @@ class UserConfigReader { 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. @@ -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()}. " + @@ -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:-" + @@ -109,7 +94,9 @@ next.sync.in.minutes=15 googleClientSecretJsonFilePath: googleClientSecretJsonFilePath, googleCalendarName: googleCalendarName, totalSyncDays: totalSyncDays, - nextSyncInMinutes: nextSyncInMinutes + nextSyncInMinutes: nextSyncInMinutes, + includeCanceledEvents: includeCanceledEvents, + includeEventBody: includeEventBody ) } @@ -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 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. * diff --git a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeClient.groovy b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeClient.groovy index 6738a70..6515bb9 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeClient.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeClient.groovy @@ -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 @@ -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)) } @@ -41,12 +40,18 @@ class ExchangeClient { * @param endDateTime End datetime * @return Events */ - List getEvents(DateTime startDateTime, DateTime endDateTime) { + List 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) + } ?: [] } + + } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy new file mode 100644 index 0000000..892a01b --- /dev/null +++ b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeEvent.groovy @@ -0,0 +1,15 @@ +package com.github.choonchernlim.calsync.exchange + +import groovy.transform.ToString +import org.joda.time.DateTime + +@ToString(includeNames = true) +class ExchangeEvent { + DateTime startDateTime + DateTime endDateTime + String subject + String location + Integer reminderMinutesBeforeStart + String body + Boolean isCanceled +} diff --git a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy index caeb9cd..6882fb1 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/exchange/ExchangeService.groovy @@ -4,7 +4,6 @@ import com.github.choonchernlim.calsync.core.CalSyncEvent import com.github.choonchernlim.calsync.core.Mapper import com.github.choonchernlim.calsync.core.UserConfig import com.google.inject.Inject -import microsoft.exchange.webservices.data.core.service.item.Appointment import org.joda.time.DateTime import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -34,24 +33,31 @@ class ExchangeService { * @param startDateTime Start datetime * @param endDateTime End datetime * @param includeCanceledEvents Whether to include canceled events or not + * @param includeEventBody Whether to include event body or not * @return Events if there's any, otherwise empty list */ - List getEvents(DateTime startDateTime, DateTime endDateTime, Boolean includeCanceledEvents) { + List getEvents( + DateTime startDateTime, + DateTime endDateTime, + Boolean includeCanceledEvents, + Boolean includeEventBody) { assert startDateTime && endDateTime && startDateTime <= endDateTime assert includeCanceledEvents != null + assert includeEventBody != null - LOGGER.info("Retrieving events from ${startDateTime} to ${endDateTime}...") + LOGGER.info( + "Retrieving events from ${Mapper.humanReadableDateTime(startDateTime)} to ${Mapper.humanReadableDateTime(endDateTime)}...") - List exchangeEvents = exchangeClient.getEvents(startDateTime, endDateTime) ?: [] + List exchangeEvents = exchangeClient.getEvents(startDateTime, endDateTime) ?: [] LOGGER.info("\tTotal events found: ${exchangeEvents.size()}...") if (!includeCanceledEvents) { - exchangeEvents = exchangeEvents.findAll { !it.isCancelled } + exchangeEvents = exchangeEvents.findAll { !it.isCanceled } LOGGER.info("\tTotal events after excluding canceled events: ${exchangeEvents.size()}...") } - return exchangeEvents.collect { Mapper.toCalSyncEvent(it) } + return exchangeEvents.collect { Mapper.toCalSyncEvent(it, includeEventBody) } } } diff --git a/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleClient.groovy b/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleClient.groovy index cd5aa70..2fc4313 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleClient.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleClient.groovy @@ -1,9 +1,6 @@ package com.github.choonchernlim.calsync.google -import com.github.choonchernlim.calsync.core.CalSyncException -import com.github.choonchernlim.calsync.core.Constant -import com.github.choonchernlim.calsync.core.Mapper -import com.github.choonchernlim.calsync.core.UserConfig +import com.github.choonchernlim.calsync.core.* import com.google.api.client.auth.oauth2.Credential import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver @@ -57,8 +54,6 @@ class GoogleClient { * @return Connected client */ com.google.api.services.calendar.Calendar getClient() { - LOGGER.info('Authenticating against Google...') - // initialize the transport HttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport() @@ -169,12 +164,14 @@ class GoogleClient { findAll { it.action == EventAction.Action.DELETE }. collect { it.event }. each { event -> + String eventSummary = prettyPrintEvent(event) + events.delete(calendarId, event.googleEventId).queue(batch, [ onSuccess: { Void content, HttpHeaders httpHeaders -> - LOGGER.info("\tEvent deleted: [event: ${event}]") + LOGGER.info("\tEvent deleted: [${eventSummary}]") }, onFailure: { GoogleJsonError error, HttpHeaders httpHeaders -> - LOGGER.error("\tError when deleting event: [event: ${event}] [error: ${error}]") + LOGGER.error("\tError when deleting event: [${eventSummary}] [error: ${error}]") } ] as JsonBatchCallback) } @@ -184,18 +181,24 @@ class GoogleClient { findAll { it.action == EventAction.Action.INSERT }. collect { it.event }. each { event -> + String eventSummary = prettyPrintEvent(event) + events.insert(calendarId, Mapper.toGoogleEvent(event)). queue(batch, [ onSuccess: { Event googleEvent, HttpHeaders httpHeaders -> - LOGGER.info("\tEvent created: [event: ${event}]") + LOGGER.info("\tEvent created: [${eventSummary}]") }, onFailure: { GoogleJsonError error, HttpHeaders httpHeaders -> LOGGER.error( - "\tError when creating event: [event: ${event}] [error: ${error}]") + "\tError when creating event: [${eventSummary}] [error: ${error}]") } ] as JsonBatchCallback) } batch.execute() } + + private static String prettyPrintEvent(CalSyncEvent event) { + return "${Mapper.humanReadableDateTime(event.startDateTime)} - ${event.subject}" + } } \ No newline at end of file diff --git a/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleService.groovy b/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleService.groovy index 4e2a67f..f72b0e1 100644 --- a/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleService.groovy +++ b/src/main/groovy/com/github/choonchernlim/calsync/google/GoogleService.groovy @@ -48,7 +48,7 @@ class GoogleService { List calendarListEntries = googleClient.getCalendarList().getItems() ?: [] List calendars = calendarListEntries.collect { it.getSummary() } - LOGGER.info("\tTotal calendars found: ${calendarListEntries.size()}... [calendars: ${calendars}]") + LOGGER.info("\tTotal calendars found: ${calendarListEntries.size()}... ${calendars}") return calendarListEntries } @@ -158,7 +158,8 @@ class GoogleService { assert calendarId?.trim() assert startDateTime && endDateTime && startDateTime <= endDateTime - LOGGER.info("Retrieving events from ${startDateTime} to ${endDateTime}...") + LOGGER.info( + "Retrieving events from ${Mapper.humanReadableDateTime(startDateTime)} to ${Mapper.humanReadableDateTime(endDateTime)}...") List events = googleClient. getEvents(calendarId, startDateTime, endDateTime). diff --git a/src/main/resources/calsync-sample.conf b/src/main/resources/calsync-sample.conf new file mode 100644 index 0000000..f01a047 --- /dev/null +++ b/src/main/resources/calsync-sample.conf @@ -0,0 +1,47 @@ +# 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 \ No newline at end of file diff --git a/src/main/resources/log4j.xml b/src/main/resources/log4j.xml index 4228c25..e8ece97 100644 --- a/src/main/resources/log4j.xml +++ b/src/main/resources/log4j.xml @@ -5,7 +5,7 @@ - + diff --git a/src/test/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleServiceSpec.groovy b/src/test/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleServiceSpec.groovy index 66317ee..5887604 100644 --- a/src/test/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleServiceSpec.groovy +++ b/src/test/groovy/com/github/choonchernlim/calsync/core/ExchangeToGoogleServiceSpec.groovy @@ -27,7 +27,9 @@ class ExchangeToGoogleServiceSpec extends Specification { googleClientSecretJsonFilePath: 'googleClientSecretJsonFilePath', googleCalendarName: googleCalendarName, totalSyncDays: 1, - nextSyncInMinutes: 15 + nextSyncInMinutes: 15, + includeCanceledEvents: true, + includeEventBody: true ) def 'run - given no exchange events and no existing google events, should do nothing'() { @@ -38,7 +40,7 @@ class ExchangeToGoogleServiceSpec extends Specification { 1 * dateTimeNowSupplier.get() >> dateTime 1 * exchangeService.init(userConfig) 1 * googleService.init(userConfig) - 1 * exchangeService.getEvents(startDateTime, endDateTime) >> [] + 1 * exchangeService.getEvents(startDateTime, endDateTime, true, true) >> [] 1 * googleService.getCalendarId(googleCalendarName) >> googleCalendarId 1 * googleService.getEvents(googleCalendarId, startDateTime, endDateTime) >> [] 1 * googleService.createBatch() >> googleService @@ -59,7 +61,7 @@ class ExchangeToGoogleServiceSpec extends Specification { 1 * dateTimeNowSupplier.get() >> dateTime 1 * exchangeService.init(userConfig) 1 * googleService.init(userConfig) - 1 * exchangeService.getEvents(startDateTime, endDateTime) >> [] + 1 * exchangeService.getEvents(startDateTime, endDateTime, true, true) >> [] 1 * googleService.getCalendarId(googleCalendarName) >> googleCalendarId 1 * googleService.getEvents(googleCalendarId, startDateTime, endDateTime) >> googleEvents 1 * googleService.createBatch() >> googleService @@ -80,7 +82,7 @@ class ExchangeToGoogleServiceSpec extends Specification { 1 * dateTimeNowSupplier.get() >> dateTime 1 * exchangeService.init(userConfig) 1 * googleService.init(userConfig) - 1 * exchangeService.getEvents(startDateTime, endDateTime) >> exchangeEvents + 1 * exchangeService.getEvents(startDateTime, endDateTime, true, true) >> exchangeEvents 1 * googleService.getCalendarId(googleCalendarName) >> googleCalendarId 1 * googleService.getEvents(googleCalendarId, startDateTime, endDateTime) >> [] 1 * googleService.createBatch() >> googleService @@ -108,7 +110,7 @@ class ExchangeToGoogleServiceSpec extends Specification { 1 * dateTimeNowSupplier.get() >> dateTime 1 * exchangeService.init(userConfig) 1 * googleService.init(userConfig) - 1 * exchangeService.getEvents(startDateTime, endDateTime) >> exchangeEvents + 1 * exchangeService.getEvents(startDateTime, endDateTime, true, true) >> exchangeEvents 1 * googleService.getCalendarId(googleCalendarName) >> googleCalendarId 1 * googleService.getEvents(googleCalendarId, startDateTime, endDateTime) >> googleEvents 1 * googleService.createBatch() >> googleService diff --git a/src/test/groovy/com/github/choonchernlim/calsync/core/MapperSpec.groovy b/src/test/groovy/com/github/choonchernlim/calsync/core/MapperSpec.groovy index b9a5f36..d845e46 100644 --- a/src/test/groovy/com/github/choonchernlim/calsync/core/MapperSpec.groovy +++ b/src/test/groovy/com/github/choonchernlim/calsync/core/MapperSpec.groovy @@ -4,9 +4,6 @@ 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.ExchangeService -import microsoft.exchange.webservices.data.core.enumeration.misc.ExchangeVersion -import microsoft.exchange.webservices.data.core.service.item.Appointment import spock.lang.Specification import spock.lang.Unroll @@ -92,34 +89,6 @@ class MapperSpec extends Specification { actual.reminderMinutesBeforeStart == 15 } - def 'toCalSyncEvent - appointment - given valid value, should return calsync event'() { - given: - def startDateTime = org.joda.time.DateTime.now() - def endDateTime = startDateTime.plusDays(1) - - def exchangeService = Mock(ExchangeService) { - getRequestedServerVersion() >> ExchangeVersion.Exchange2010_SP2 - } - def appointment = new Appointment(exchangeService) - - appointment.start = startDateTime.toDate() - appointment.end = endDateTime.toDate() - appointment.subject = 'summary' - appointment.location = 'location' - appointment.reminderMinutesBeforeStart = 15 - - when: - def actual = Mapper.toCalSyncEvent(appointment) - - then: - actual.googleEventId == null - actual.startDateTime == startDateTime - actual.endDateTime == endDateTime - actual.subject == 'summary' - actual.location == 'location' - actual.reminderMinutesBeforeStart == 15 - } - def 'toGoogleEvent - given valid value, should return google event'() { given: def startDateTime = org.joda.time.DateTime.now() @@ -149,4 +118,61 @@ class MapperSpec extends Specification { reminders[0].getMinutes() == 15 reminders[0].getMethod() == 'popup' } + + @Unroll + def 'toPlainText - given #label value, should be null'() { + when: + def actual = Mapper.toPlainText(input) + + then: + actual == null + + where: + label | input + 'null' | null + 'blank' | ' ' + } + + def 'toPlainText - given valid HTML, should return plain text'() { + given: + def input = '
123 / 456
 
12/9/2016 Added room location – limc
        Room Reservation # 999
 
 
' + + when: + def actual = Mapper.toPlainText(input) + + then: + actual == '''123 / 456 + +12/9/2016 Added room location – limc + + Room Reservation # 999''' + } + + def 'toPlainText - given valid HTML but no content, should return null'() { + given: + def input = '
' + + when: + def actual = Mapper.toPlainText(input) + + then: + actual == null + } + + def 'humanReadableDateTime - given null value, should throw exception'() { + when: + Mapper.humanReadableDateTime(null) + + then: + thrown AssertionError + } + + def 'humanReadableDateTime - given valid value, should return formatted datetime'() { + when: + def actual = Mapper.humanReadableDateTime(new org.joda.time.DateTime(2016, 12, 12, 9, 10, 11, 12)) + + then: + actual == 'Dec 12 @ 09:10 AM' + } + } diff --git a/src/test/groovy/com/github/choonchernlim/calsync/core/UserConfigReaderSpec.groovy b/src/test/groovy/com/github/choonchernlim/calsync/core/UserConfigReaderSpec.groovy index f6ed211..a14bfc4 100644 --- a/src/test/groovy/com/github/choonchernlim/calsync/core/UserConfigReaderSpec.groovy +++ b/src/test/groovy/com/github/choonchernlim/calsync/core/UserConfigReaderSpec.groovy @@ -17,6 +17,8 @@ class UserConfigReaderSpec extends Specification { properties.setProperty(UserConfigReader.GOOGLE_CALENDAR_NAME_KEY, 'CALENDAR_NAME') properties.setProperty(UserConfigReader.TOTAL_SYNC_IN_DAYS_KEY, '1') properties.setProperty(UserConfigReader.NEXT_SYNC_IN_MINUTES_KEY, '15') + properties.setProperty(UserConfigReader.INCLUDE_CANCELED_EVENTS_KEY, 'true') + properties.setProperty(UserConfigReader.INCLUDE_EVENT_BODY_KEY, 'true') System.metaClass.'static'.getenv = { String var -> switch (var) { @@ -61,6 +63,8 @@ google.client.secret.json.file.path=CLIENT_JSON google.calendar.name=CALENDAR_NAME total.sync.in.days=1 next.sync.in.minutes=15 +include.canceled.events=true +include.event.body=false ''') when: @@ -74,6 +78,8 @@ next.sync.in.minutes=15 userConfig.googleCalendarName == 'CALENDAR_NAME' userConfig.totalSyncDays == 1 userConfig.nextSyncInMinutes == 15 + userConfig.includeCanceledEvents + !userConfig.includeEventBody cleanup: assert file.delete() @@ -118,6 +124,10 @@ next.sync.in.minutes=15 'zero' | UserConfigReader.TOTAL_SYNC_IN_DAYS_KEY | '0' 'blank' | UserConfigReader.NEXT_SYNC_IN_MINUTES_KEY | ' ' 'not integer' | UserConfigReader.NEXT_SYNC_IN_MINUTES_KEY | 'val' + 'blank' | UserConfigReader.INCLUDE_CANCELED_EVENTS_KEY | ' ' + 'not boolean' | UserConfigReader.INCLUDE_CANCELED_EVENTS_KEY | 'val' + 'blank' | UserConfigReader.INCLUDE_EVENT_BODY_KEY | ' ' + 'not boolean' | UserConfigReader.INCLUDE_EVENT_BODY_KEY | 'val' } @Unroll diff --git a/src/test/groovy/com/github/choonchernlim/calsync/exchange/ExchangeServiceSpec.groovy b/src/test/groovy/com/github/choonchernlim/calsync/exchange/ExchangeServiceSpec.groovy index 671a265..2e5b372 100644 --- a/src/test/groovy/com/github/choonchernlim/calsync/exchange/ExchangeServiceSpec.groovy +++ b/src/test/groovy/com/github/choonchernlim/calsync/exchange/ExchangeServiceSpec.groovy @@ -1,8 +1,6 @@ package com.github.choonchernlim.calsync.exchange import com.github.choonchernlim.calsync.core.UserConfig -import microsoft.exchange.webservices.data.core.enumeration.misc.ExchangeVersion -import microsoft.exchange.webservices.data.core.service.item.Appointment import org.joda.time.DateTime import spock.lang.Specification import spock.lang.Unroll @@ -37,7 +35,7 @@ class ExchangeServiceSpec extends Specification { @Unroll def 'getEvents - given #label, should throw exception'() { when: - service.getEvents(startDateTime, endDateTime, true) + service.getEvents(startDateTime, endDateTime, true, true) then: 0 * _ @@ -56,7 +54,20 @@ class ExchangeServiceSpec extends Specification { def endDateTime = startDateTime.plusDays(1) when: - service.getEvents(startDateTime, endDateTime, null) + service.getEvents(startDateTime, endDateTime, null, true) + + then: + 0 * _ + thrown AssertionError + } + + def 'getEvents - given includeEventBody == null, should throw exception'() { + given: + def startDateTime = DateTime.now() + def endDateTime = startDateTime.plusDays(1) + + when: + service.getEvents(startDateTime, endDateTime, true, null) then: 0 * _ @@ -70,7 +81,7 @@ class ExchangeServiceSpec extends Specification { def endDateTime = startDateTime.plusDays(1) when: - def events = service.getEvents(startDateTime, endDateTime, true) + def events = service.getEvents(startDateTime, endDateTime, true, true) then: 1 * exchangeClient.getEvents(startDateTime, endDateTime) >> null @@ -79,50 +90,86 @@ class ExchangeServiceSpec extends Specification { events.isEmpty() } - def 'getEvents - given events found, should return events'() { + def 'getEvents - given includeCanceledEvent == true and includeEventBody == true, should return all events with body'() { given: def startDateTime = DateTime.now() def endDateTime = startDateTime.plusDays(1) when: - def events = service.getEvents(startDateTime, endDateTime, true) + def events = service.getEvents(startDateTime, endDateTime, true, true) then: 1 * exchangeClient.getEvents(startDateTime, endDateTime) >> [ - createAppointment(startDateTime, endDateTime, 'summary1', 'location1', 15, true), - createAppointment(startDateTime, endDateTime, 'summary2', 'location2', 15, false) + createExchangeEvent(startDateTime, endDateTime, 'summary1', 'location1', 15, true, 'body1'), + createExchangeEvent(startDateTime, endDateTime, 'summary2', 'location2', 15, false, 'body2') ] 0 * _ events.size() == 2 events*.subject == ['summary1', 'summary2'] + events*.body == ['body1', 'body2'] } - private Appointment createAppointment( + def 'getEvents - given includeCanceledEvent == false and includeEventBody == true, should return all non-canceled events with body'() { + given: + def startDateTime = DateTime.now() + def endDateTime = startDateTime.plusDays(1) + + when: + def events = service.getEvents(startDateTime, endDateTime, false, true) + + then: + 1 * exchangeClient.getEvents(startDateTime, endDateTime) >> [ + createExchangeEvent(startDateTime, endDateTime, 'summary1', 'location1', 15, true, 'body1'), + createExchangeEvent(startDateTime, endDateTime, 'summary2', 'location2', 15, false, 'body2') + ] + 0 * _ + + events.size() == 1 + events*.subject == ['summary2'] + events*.body == ['body2'] + } + + def 'getEvents - given includeCanceledEvent == true and includeEventBody == false, should return all events with no body'() { + given: + def startDateTime = DateTime.now() + def endDateTime = startDateTime.plusDays(1) + + when: + def events = service.getEvents(startDateTime, endDateTime, true, false) + + then: + 1 * exchangeClient.getEvents(startDateTime, endDateTime) >> [ + createExchangeEvent(startDateTime, endDateTime, 'summary1', 'location1', 15, true, 'body1'), + createExchangeEvent(startDateTime, endDateTime, 'summary2', 'location2', 15, false, 'body2') + ] + 0 * _ + + events.size() == 2 + events*.subject == ['summary1', 'summary2'] + events*.body == [null, null] + } + + private static ExchangeEvent createExchangeEvent( DateTime startDateTime, DateTime endDateTime, String subject, String location, Integer reminderMinutesBeforeStart, - Boolean isCancelled) { - - def exchangeService = Mock(microsoft.exchange.webservices.data.core.ExchangeService) { - getRequestedServerVersion() >> ExchangeVersion.Exchange2010_SP2 - } - - def appointment = new Appointment(exchangeService) + Boolean isCancelled, + String body) { - // TODO You must load or assign this property before you can read its value. - // appointment.load(new PropertySet(BasePropertySet.FirstClassProperties)) - appointment.start = startDateTime.toDate() - appointment.end = endDateTime.toDate() - appointment.subject = subject - appointment.location = location - appointment.reminderMinutesBeforeStart = reminderMinutesBeforeStart - appointment.isCancelled == isCancelled + return new ExchangeEvent( + startDateTime: startDateTime, + endDateTime: endDateTime, + subject: subject, + location: location, + reminderMinutesBeforeStart: reminderMinutesBeforeStart, + isCanceled: isCancelled, + body: body - return appointment + ) } diff --git a/src/test/resources/log4j.xml b/src/test/resources/log4j.xml new file mode 100644 index 0000000..b7d6898 --- /dev/null +++ b/src/test/resources/log4j.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file