/**
* Copyright (c) 2010-2016 by the respective copyright holders.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.openhab.io.gcal.internal;
import static org.quartz.JobBuilder.newJob;
import static org.quartz.TriggerBuilder.newTrigger;
import static org.quartz.impl.matchers.GroupMatcher.jobGroupEquals;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.meta.When;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.LongRange;
import org.openhab.core.service.AbstractActiveService;
import org.openhab.io.gcal.auth.GCalGoogleOAuth;
import org.openhab.io.gcal.internal.util.ExecuteCommandJob;
import org.openhab.io.gcal.internal.util.TimeRangeCalendar;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.quartz.Job;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.impl.StdSchedulerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.DateTime;
import com.google.api.services.calendar.Calendar;
import com.google.api.services.calendar.model.CalendarListEntry;
import com.google.api.services.calendar.model.Event;
import com.google.api.services.calendar.model.EventDateTime;
import com.google.api.services.calendar.model.Events;
/**
* Service which downloads Calendar events, parses their content and creates
* Quartz-jobs and triggers out of them.
*
* @author Thomas.Eichstaedt-Engelen
* @since 0.7.0
*/
public class GCalEventDownloader extends AbstractActiveService implements ManagedService {
private static final String GCAL_SCHEDULER_GROUP = "gcal";
private static final Logger logger = LoggerFactory.getLogger(GCalEventDownloader.class);
private static String calendar_name = "primary";
private static String filter = "";
/** holds the current refresh interval, default to 900000ms (15 minutes) */
public static int refreshInterval = 900000;
/** holds the local quartz scheduler instance */
private Scheduler scheduler;
/**
* RegEx to extract the start and end commands from the Calendar-Event content.
* (<code>'start\s*?\{(.*?)\}\s*end\s*?\{(.*?)\}\s*'</code>)
*/
private static final Pattern EXTRACT_STARTEND_CONTENT = Pattern
.compile("start\\s*?\\{(.*?)\\}\\s*end\\s*?\\{(.*?)\\}\\s*", Pattern.DOTALL);
/**
* RegEx to extract the modified by command from the Calendar-Event content.
* (<code>'(.*?)modified by\s*?\{(.*?)\}.*'</code>)
*/
private static final Pattern EXTRACT_MODIFIEDBY_CONTENT = Pattern.compile("(.*?)modified by\\s*?\\{(.*?)\\}.*",
Pattern.DOTALL);
public static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
/**
* Define a global instance of the JSON factory.
*/
public static final JsonFactory JSON_FACTORY = new JacksonFactory();
@Override
protected long getRefreshInterval() {
return refreshInterval;
}
@Override
protected String getName() {
return "Google Calendar Event Downloader";
}
@Override
public void activate() {
try {
scheduler = StdSchedulerFactory.getDefaultScheduler();
super.activate();
} catch (SchedulerException se) {
logger.error("initializing scheduler throws exception", se);
}
}
/**
* @{inheritDoc}
*/
@Override
protected void execute() {
Events myFeed = downloadEventFeed();
if (myFeed != null) {
List<Event> entries = myFeed.getItems();
if (entries.size() > 0) {
logger.debug("found {} calendar events to process", entries.size());
try {
if (scheduler.isShutdown()) {
logger.warn("Scheduler has been shut down - probably due to exceptions?");
}
cleanJobs();
processEntries(entries);
} catch (SchedulerException se) {
logger.error("scheduling jobs throws exception", se);
}
} else {
logger.debug("gcal feed contains no events ...");
}
}
}
/**
* Connects to Google-Calendar Service and returns the specified Events
*
* @return the corresponding Events or <code>null</code> if an error
* occurs. <i>Note:</i> We do only return events if their startTime lies between
* <code>now</code> and <code>now + 2 * refreshInterval</code> to reduce
* the amount of events to process.
*/
private static Events downloadEventFeed() {
// TODO: teichsta: there could be more than one calendar url in openHAB.cfg
// for now we accept this limitation of downloading just one feed ...
if (StringUtils.isBlank(calendar_name)) {
logger.warn("Login aborted no calendar name defined");
return null;
}
// authorization
CalendarListEntry calendarID = GCalGoogleOAuth.getCalendarId(calendar_name);
if (calendarID == null) {
return null;
}
DateTime start = new DateTime(new Date(), TimeZone.getTimeZone(calendarID.getTimeZone()));
DateTime end = new DateTime(new Date(start.getValue() + (2 * refreshInterval)),
TimeZone.getTimeZone(calendarID.getTimeZone()));
logger.debug("Downloading calendar feed for time interval: {} to {} ", start, end);
Events feed = null;
try {
Credential credential = GCalGoogleOAuth.getCredential(false);
// set up global Calendar instance
Calendar client = new Calendar.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential)
.setApplicationName("openHAB").build();
Calendar.Events.List l = client.events().list(calendarID.getId()).setSingleEvents(true).setTimeMin(start)
.setTimeMax(end);
// add the fulltext filter if it has been configured
if (StringUtils.isNotBlank(filter)) {
l = l.setQ(filter);
}
feed = l.execute();
} catch (IOException e1) {
logger.error("Event fetch failed: {}", e1.getMessage());
}
try {
if (feed != null) {
checkIfFullCalendarFeed(feed.getItems());
}
return feed;
} catch (Exception e) {
logger.error("downloading CalendarEventFeed throws exception: {}", e.getMessage());
}
return null;
}
/**
* Checks the first {@link CalendarEventEntry} of <code>entries</code> for
* completeness. If this first event is incomplete all other events will be
* incomplete as well.
*
* @param list the set to check
*/
private static void checkIfFullCalendarFeed(List<Event> list) {
if (list != null && !list.isEmpty()) {
Event referenceEvent = list.get(0);
if (referenceEvent.getICalUID() == null || referenceEvent.getStart().toString().isEmpty()) {
logger.warn("calendar entries are incomplete - please make sure to use the full calendar feed");
}
}
}
/**
* Delete all {@link Job}s of the group <code>GCAL_SCHEDULER_GROUP</code>
*
* @throws SchedulerException if there is an internal Scheduler error.
*/
private void cleanJobs() throws SchedulerException {
Set<JobKey> jobKeys = scheduler.getJobKeys(jobGroupEquals(GCAL_SCHEDULER_GROUP));
scheduler.deleteJobs(new ArrayList<JobKey>(jobKeys));
}
/**
* <p>
* Iterates through <code>entries</code>, extracts the event content and
* creates quartz calendars, jobs and corresponding triggers for each event.
* </p>
* <p>
* The following steps are done at event processing:
* <ul>
* <li>find events with empty content</li>
* <li>create a {@link TimeRangeCalendar} for each event (unique by title) and add a TimeRange for each {@link When}
* </li>
* <li>add each {@link TimeRangeCalendar} to the {@link Scheduler}</li>
* <li>find events with content</li>
* <li>add a Job with the corresponding Triggers for each event</li>
* </ul>
*
* @param entries the GCalendar events to create quart jobs for.
* @throws SchedulerException if there is an internal Scheduler error.
*/
private void processEntries(List<Event> entries) throws SchedulerException {
Map<String, TimeRangeCalendar> calendarCache = new HashMap<String, TimeRangeCalendar>();
// find all events with empty content - these events are taken to modify
// the scheduler
for (Event event : entries) {
String eventContent = event.getDescription();
String eventTitle = event.getSummary();
if (StringUtils.isBlank(eventContent)) {
logger.debug(
"found event '{}' with no content, add this event to the excluded TimeRangesCalendar - this event could be referenced by the modifiedBy clause",
eventTitle);
if (!calendarCache.containsKey(eventTitle)) {
calendarCache.put(eventTitle, new TimeRangeCalendar());
}
TimeRangeCalendar timeRangeCalendar = calendarCache.get(eventTitle);
timeRangeCalendar.addTimeRange(new LongRange(event.getStart().getDateTime().getValue(),
event.getEnd().getDateTime().getValue()));
}
}
// add all calendars to the Scheduler an rebase all existing Triggers
// the calendars has to be added first, to schedule Triggers successfully
for (Entry<String, TimeRangeCalendar> entry : calendarCache.entrySet()) {
scheduler.addCalendar(entry.getKey(), entry.getValue(), true, true);
}
// now we process all events with content
for (Event event : entries) {
String eventContent = event.getDescription();
String eventTitle = event.getSummary();
if (StringUtils.isNotBlank(eventContent)) {
CalendarEventContent cec = parseEventContent(eventContent,
(eventTitle != null) && eventTitle.startsWith("[PresenceSimulation]"));
String modifiedByEvent = null;
if (calendarCache.containsKey(cec.modifiedByEvent)) {
modifiedByEvent = cec.modifiedByEvent;
}
JobDetail startJob = createJob(cec.startCommands, event, true);
boolean triggersCreated = createTriggerAndSchedule(startJob, event, modifiedByEvent, true);
if (triggersCreated) {
logger.debug("created new startJob '{}' with details '{}'", eventTitle,
createJobInfo(event, startJob));
}
// do only create end-jobs if there are end-commands ...
if (StringUtils.isNotBlank(cec.endCommands)) {
JobDetail endJob = createJob(cec.endCommands, event, false);
triggersCreated = createTriggerAndSchedule(endJob, event, modifiedByEvent, false);
if (triggersCreated) {
logger.debug("created new endJob '{}' with details '{}'", eventTitle,
createJobInfo(event, endJob));
}
}
}
}
}
/**
* <p>
* Extracts start, end and modified by-commands from <code>content</code>.
* Start-Commands will be executed at start-time and End-Commands will be
* executed at end-time of the calendar-event. The modified-by command defines
* the name of special event which disables the created Job temporarily.
* </p>
* <p>
* If the RegExp <code>EXTRACT_STARTEND_CONTENT</code> doen't match the
* complete content is taken as set of Start-Commands.
* </p>
*
* @param content the set of Start- and End-Commands
* @return the parsed event content
*/
protected CalendarEventContent parseEventContent(String content, boolean presenceSimulation) {
CalendarEventContent eventContent = new CalendarEventContent();
String commandContent;
Matcher modifiedByMatcher = EXTRACT_MODIFIEDBY_CONTENT.matcher(content);
if (modifiedByMatcher.find()) {
commandContent = modifiedByMatcher.group(1);
eventContent.modifiedByEvent = StringUtils.trimToEmpty(modifiedByMatcher.group(2));
} else {
commandContent = content;
}
Matcher startEndMatcher = EXTRACT_STARTEND_CONTENT.matcher(commandContent);
if (startEndMatcher.find()) {
eventContent.startCommands = StringUtils.trimToEmpty(startEndMatcher.group(1));
eventContent.endCommands = StringUtils.trimToEmpty(startEndMatcher.group(2));
} else {
if (presenceSimulation) {
eventContent.startCommands = StringUtils.trimToEmpty("[PresenceSimulation]" + "\n" + commandContent);
} else {
eventContent.startCommands = StringUtils.trimToEmpty(commandContent);
}
logger.debug(
"given event content doesn't match regular expression to extract start-, end commands - using whole content as startCommand ({})",
commandContent);
}
return eventContent;
}
/**
* Creates a new quartz-job with jobData <code>content</code> in the scheduler
* group <code>GCAL_SCHEDULER_GROUP</code> if <code>content</code> is not
* blank.
*
* @param content the set of commands to be executed by the
* {@link ExecuteCommandJob} later on
* @param event
* @param isStartEvent indicator to identify whether this trigger will be
* triggering a start or an end command.
*
* @return the {@link JobDetail}-object to be used at further processing
*/
protected JobDetail createJob(String content, Event event, boolean isStartEvent) {
String jobIdentity = event.getICalUID() + (isStartEvent ? "_start" : "_end");
if (StringUtils.isBlank(content)) {
logger.debug("content of job '{}' is empty -> no task will be created!", jobIdentity);
return null;
}
JobDetail job = newJob(ExecuteCommandJob.class).usingJobData(ExecuteCommandJob.JOB_DATA_CONTENT_KEY, content)
.withIdentity(jobIdentity, GCAL_SCHEDULER_GROUP).build();
return job;
}
/**
* Creates a set quartz-triggers for <code>job</code>. For each {@link When}
* object of <code>event</code> a new trigger is created. That is the case
* in recurring events where gcal creates one event (with one unique IcalUID)
* and a set of {@link When}-object for each occurrence.
*
* @param job the {@link Job} to create triggers for
* @param event the {@link CalendarEventEntry} to read the {@link When}-objects
* from
* @param modifiedByEvent defines the name of an event which modifies the
* schedule of the new Trigger
* @param isStartEvent indicator to identify whether this trigger will be
* triggering a start or an end command.
*
* @throws SchedulerException if there is an internal Scheduler error.
*/
protected boolean createTriggerAndSchedule(JobDetail job, Event event, String modifiedByEvent,
boolean isStartEvent) {
boolean triggersCreated = false;
if (job == null) {
logger.debug("job is null -> no triggers are created");
return false;
}
String jobIdentity = event.getICalUID() + (isStartEvent ? "_start" : "_end");
EventDateTime date = isStartEvent ? event.getStart() : event.getEnd();
long dateValue = date.getDateTime().getValue();
/*
* TODO: TEE: do only create a new trigger when the start/endtime
* lies in the future. This exclusion is necessary because the SimpleTrigger
* triggers a job even if the startTime lies in the past. If somebody
* knows the way to let quartz ignore such triggers this exclusion
* can be omitted.
*/
if (dateValue >= (new Date()).getTime()) {
Trigger trigger;
if (StringUtils.isBlank(modifiedByEvent)) {
trigger = newTrigger().forJob(job)
.withIdentity(jobIdentity + "_" + dateValue + "_trigger", GCAL_SCHEDULER_GROUP)
.startAt(new Date(dateValue)).build();
} else {
trigger = newTrigger().forJob(job)
.withIdentity(jobIdentity + "_" + dateValue + "_trigger", GCAL_SCHEDULER_GROUP)
.startAt(new Date(dateValue)).modifiedByCalendar(modifiedByEvent).build();
}
try {
scheduler.scheduleJob(job, trigger);
triggersCreated = true;
} catch (SchedulerException se) {
logger.warn("scheduling Trigger '{}' throws an exception: {}", trigger, se);
}
}
// }
return triggersCreated;
}
/**
* Creates a detailed description of a <code>job</code> for logging purpose.
*
* @param job the job to create a detailed description for
* @return a detailed description of the new <code>job</code>
*/
private String createJobInfo(Event event, JobDetail job) {
if (job == null) {
return "SchedulerJob [null]";
}
StringBuffer sb = new StringBuffer();
sb.append("SchedulerJob [jobKey=").append(job.getKey().getName());
sb.append(", jobGroup=").append(job.getKey().getGroup());
try {
List<? extends Trigger> triggers = scheduler.getTriggersOfJob(job.getKey());
sb.append(", ").append(triggers.size()).append(" triggers=[");
int maxTriggerLogs = 24;
for (int triggerIndex = 0; triggerIndex < triggers.size()
&& triggerIndex < maxTriggerLogs; triggerIndex++) {
Trigger trigger = triggers.get(triggerIndex);
sb.append(trigger.getStartTime());
if (triggerIndex < triggers.size() - 1 && triggerIndex < maxTriggerLogs - 1) {
sb.append(", ");
}
}
if (triggers.size() >= maxTriggerLogs) {
sb.append(" and ").append(triggers.size() - maxTriggerLogs).append(" more ...");
}
if (triggers.size() == 0) {
sb.append("there are no triggers - probably the event lies in the past");
}
} catch (SchedulerException e) {
}
sb.append("], content=").append(event.getDescription());
return sb.toString();
}
/**
* Holds the parsed content of a GCal event
*
* @author Thomas.Eichstaedt-Engelen
*/
class CalendarEventContent {
String startCommands = "";
String endCommands = "";
String modifiedByEvent = "";
}
@Override
public void updated(Dictionary<String, ?> config) throws ConfigurationException {
if (config != null) {
String usernameString = (String) config.get("client_id");
if (!StringUtils.isBlank(usernameString)) {
GCalGoogleOAuth.setClientId(usernameString);
} else {
logger.error(
"gcal:client_id must be configured in openhab.cfg. Refer to wiki how to create client_id/client_secret pair");
throw new ConfigurationException("client_id",
"gcal:client_id must be configured in openhab.cfg. Refer to wiki how to create client_id/client_secret");
}
String passwordString = (String) config.get("client_secret");
if (!StringUtils.isBlank(passwordString)) {
GCalGoogleOAuth.setClientSecret(passwordString);
} else {
logger.error(
"gcal:client_secret must be configured in openhab.cfg. Refer to wiki how to create client_id/client_secret pair");
throw new ConfigurationException("client_secret",
"gcal:client_secret must be configured in openhab.cfg. Refer to wiki how to create client_id/client_secret pair");
}
String urlString = (String) config.get("calendar_name");
if (!StringUtils.isBlank(urlString)) {
calendar_name = urlString;
} else {
logger.error(
"gcal:calendar_name must be configured in openhab.cfg. Calendar name or word \"primary\" MUST be specified");
throw new ConfigurationException("calendar_name",
"gcal:calendar_name must be configured in openhab.cfg. Calendar name or word \"primary\" MUST be specified");
}
filter = (String) config.get("filter");
String refreshString = (String) config.get("refresh");
if (StringUtils.isNotBlank(refreshString)) {
refreshInterval = Integer.parseInt(refreshString);
}
if (GCalGoogleOAuth.getCredential(true) == null) {
logger.error("Cannnot obtain credential based on provided client_id/client_secret");
throw new ConfigurationException("Credential error",
"Cannnot obtain credential based on provided client_id/client_secret");
}
setProperlyConfigured(true);
}
}
}