com.almende.eve.agent.MeetingAgent.java Source code

Java tutorial

Introduction

Here is the source code for com.almende.eve.agent.MeetingAgent.java

Source

/**
 * @brief
 *        The MeetingAgent can dynamically schedule a meeting with multiple
 *        attendees.
 * 
 *        The MeetingAgent synchronizes a meeting for one or multiple attendees,
 *        and dynamically schedules the meeting on a free time slot in all
 *        calendars,
 *        reckoning with office hours (Mon-Fri, 9:00-17:00, CET). The duration,
 *        summary, location, and one or multiple attendees can be specified.
 *        After having created a meeting, the meeting can be updated (add/remove
 *        attendees, change summary, duration, start time, etc.). The meetings
 *        can be
 *        changed in both your Google Calendar and via the MeetingAgent itself.
 * 
 *        The MeetingAgent regularly checks for updates its meeting and
 *        reschedules it
 *        when needed. The update frequency depends on the time the meeting was
 *        last
 *        changed. When just changed, the MeetingAgent checks every 10 seconds,
 *        and
 *        this interval is linearly decreased towards once an hour.
 * 
 *        The MeetingAgent uses Activity as data structure, and uses this
 *        structure
 *        to describe a meeting. To setup a MeetingAgent call the method
 *        setActivity
 *        or updateActivity. The MeetingAgent will automatically start
 *        scheduling and
 *        monitoring the meeting, and stops with monitoring once the event is
 *        past.
 * 
 *        Core methods are:
 *        setActivity Clear current meeting and setup a new meeting
 *        updateActivity Update current meeting
 *        update Force an update: synchronize and reschedule the meeting
 *        clear Remove the meetings from the attendees calendars, and
 *        delete all stored information.
 * 
 *        A minimal, valid Activity structure looks like:
 *        {
 *        "summary": "Test C",
 *        "constraints": {
 *        "attendees": [
 *        {
 *        "agent": "http://myserver.com/agents/googlecalendaragent/123/",
 *        },
 *        {
 *        "agent": "http://myserver.com/agents/googlecalendaragent/456/",
 *        }
 *        ]
 *        }
 *        }
 * 
 * 
 * @license
 *          Licensed under the Apache License, Version 2.0 (the "License"); you
 *          may not
 *          use this file except in compliance with the License. You may obtain
 *          a copy
 *          of the License at
 * 
 *          http://www.apache.org/licenses/LICENSE-2.0
 * 
 *          Unless required by applicable law or agreed to in writing, software
 *          distributed under the License is distributed on an "AS IS" BASIS,
 *          WITHOUT
 *          WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See
 *          the
 *          License for the specific language governing permissions and
 *          limitations under
 *          the License.
 * 
 *          Copyright  2012 Almende B.V.
 * 
 * @author Jos de Jong, <jos@almende.org>
 * @date 2012-08-09
 */

package com.almende.eve.agent;

import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.joda.time.DateTime;
import org.joda.time.Duration;
import org.joda.time.Interval;
import org.joda.time.MutableDateTime;

import com.almende.eve.entity.Issue;
import com.almende.eve.entity.Issue.TYPE;
import com.almende.eve.entity.Weight;
import com.almende.eve.entity.activity.Activity;
import com.almende.eve.entity.activity.Attendee;
import com.almende.eve.entity.activity.Attendee.RESPONSE_STATUS;
import com.almende.eve.entity.activity.Preference;
import com.almende.eve.entity.activity.Status;
import com.almende.eve.entity.calendar.AgentData;
import com.almende.eve.rpc.annotation.Access;
import com.almende.eve.rpc.annotation.AccessType;
import com.almende.eve.rpc.annotation.Name;
import com.almende.eve.rpc.annotation.Optional;
import com.almende.eve.rpc.jsonrpc.JSONRPCException;
import com.almende.eve.rpc.jsonrpc.JSONRequest;
import com.almende.eve.rpc.jsonrpc.jackson.JOM;
import com.almende.eve.state.State;
import com.almende.util.IntervalsUtil;
import com.almende.util.TypeUtil;
import com.almende.util.WeightsUtil;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

/**
 * The Class MeetingAgent.
 */
@Access(AccessType.PUBLIC)
public class MeetingAgent extends Agent {
    private static final Logger LOG = Logger.getLogger(MeetingAgent.class.getName());
    /* number of days to look ahead when planning a meeting */
    private static final int LOOK_AHEAD_DAYS = 7;
    private static final Double WEIGHT_BUSY_OPTIONAL_ATTENDEE = -1.0;
    private static final Double WEIGHT_OFFICE_HOURS = 10.0;
    private static final Double WEIGHT_PREFERRED_INTERVAL = 0.1;
    // private static final Double WEIGHT_UNDESIRED_INTERVAL = -0.1;
    private static final Double WEIGHT_DELAY_PER_DAY = -0.1;

    /**
     * Convenience method to quickly set a new activity.
     * Currently stored activity will be removed.
     * 
     * @param summary
     *            Description for the meeting
     * @param location
     *            the location
     * @param duration
     *            Duration in minutes
     * @param agents
     *            List with calendar agent urls of the attendees
     */
    public void setActivityQuick(@Name("summary") final String summary,
            @Optional @Name("location") final String location, @Name("duration") final Integer duration,
            @Name("agents") final List<String> agents) {
        final Activity activity = new Activity();
        activity.setSummary(summary);
        activity.withConstraints().withLocation().setSummary(location);
        for (final String agent : agents) {
            final Attendee attendee = new Attendee();
            attendee.setAgent(agent);
            activity.withConstraints().withAttendees().add(attendee);
        }

        update();
    }

    /**
     * Set a new activity. Currently stored activity will be removed.
     * 
     * @param activity
     *            the activity
     * @return the activity
     * @throws Exception
     *             the exception
     */
    public Activity setActivity(@Name("activity") final Activity activity) throws Exception {
        clear();

        return updateActivity(activity);
    }

    /**
     * Get a set with attendee agent urls from an activity returns an empty list
     * when no attendees
     * 
     * @param activity
     * @return
     */
    private Set<String> getAgents(final Activity activity) {
        final Set<String> agents = new TreeSet<String>();
        for (final Attendee attendee : activity.withConstraints().withAttendees()) {
            final String agent = attendee.getAgent();
            if (agent != null) {
                agents.add(agent);
            }
        }

        return agents;
    }

    /**
     * update the activity for meeting agent.
     * 
     * @param updatedActivity
     *            the updated activity
     * @return the activity
     * @throws Exception
     *             the exception
     */
    public Activity updateActivity(@Name("activity") final Activity updatedActivity) throws Exception {
        Activity activity = getState().get("activity", Activity.class);
        if (activity == null) {
            activity = new Activity();
        }

        final Set<String> prevAttendees = getAgents(activity);

        // if no updated timestamp is provided, set the timestamp to now
        if (updatedActivity.withStatus().getUpdated() == null) {
            updatedActivity.withStatus().setUpdated(DateTime.now().toString());
        }

        // synchronize with the stored activity
        activity = Activity.sync(activity, updatedActivity);

        // ensure the url of the meeting agent is filled in
        final URI myUrl = getFirstUrl("http");
        activity.setAgent(myUrl);

        // create duration when missing
        Long duration = activity.withConstraints().withTime().getDuration();
        if (duration == null) {
            duration = Duration.standardHours(1).getMillis(); // 1 hour in ms
            activity.withConstraints().withTime().setDuration(duration);
        }

        // remove calendar events from removed attendees
        final Set<String> currentAttendees = getAgents(activity);
        final Set<String> removedAttendees = new TreeSet<String>(prevAttendees);
        removedAttendees.removeAll(currentAttendees);
        for (final String attendee : removedAttendees) {
            clearAttendee(attendee);
        }

        getState().put("activity", activity);

        // update all attendees, start timer to regularly check
        update();

        return getState().get("activity", Activity.class);
    }

    /**
     * Get meeting summary.
     * 
     * @return the summary
     */
    public String getSummary() {
        final Activity activity = getState().get("activity", Activity.class);
        return (activity != null) ? activity.getSummary() : null;
    }

    /**
     * get meeting activity returns null if no activity has been initialized.
     * 
     * @return activity
     */
    public Activity getActivity() {
        return getState().get("activity", Activity.class);
    }

    /**
     * The the complete state of the agent.
     * TODO: remove this temporary method
     * 
     * @return state
     */
    public Object getEverything() {
        return getState();
    }

    /**
     * Apply the constraints of the the activity (for example duration)
     * 
     * @param activity
     * @return changed Returns true if the activity is changed
     */
    private boolean applyConstraints() {
        final Activity activity = getState().get("activity", Activity.class);
        boolean changed = false;
        if (activity == null) {
            return false;
        }

        // constraints on attendees/resources
        /*
         * TODO: copy actual attendees to status.attendees
         * List<Attendee> constraintsAttendees =
         * activity.withConstraints().withAttendees();
         * List<Attendee> attendees = new ArrayList<Attendee>();
         * for (Attendee attendee : constraintsAttendees) {
         * attendees.add(attendee.clone());
         * }
         * activity.withStatus().setAttendees(attendees);
         * // TODO: is it needed to check if the attendees are changed?
         */

        // check time constraints
        final Long duration = activity.withConstraints().withTime().getDuration();
        if (duration != null) {
            final String start = activity.withStatus().getStart();
            final String end = activity.withStatus().getEnd();
            if (start != null && end != null) {
                final DateTime startTime = new DateTime(start);
                DateTime endTime = new DateTime(end);
                final Interval interval = new Interval(startTime, endTime);
                if (interval.toDurationMillis() != duration) {
                    LOG.info("status did not match constraints. " + "Changed end time to match the duration of "
                            + duration + " ms");

                    // duration does not match. adjust the end time
                    endTime = startTime.plus(duration);
                    activity.withStatus().setEnd(endTime.toString());
                    activity.withStatus().setUpdated(DateTime.now().toString());

                    changed = true;
                }
            }
        }

        // location constraints
        final String newLocation = activity.withConstraints().withLocation().getSummary();
        final String oldLocation = activity.withStatus().withLocation().getSummary();
        if (newLocation != null && !newLocation.equals(oldLocation)) {
            activity.withStatus().withLocation().setSummary(newLocation);
            changed = true;
        }

        if (changed) {
            // store the updated activity
            getState().put("activity", activity);
        }
        return changed;
    }

    /**
     * synchronize the meeting in all attendees calendars
     */
    private boolean syncEvents() {
        LOG.info("syncEvents started");
        Activity activity = getActivity();

        boolean changed = false;
        if (activity != null) {
            final String updatedBefore = activity.withStatus().getUpdated();

            for (final Attendee attendee : activity.withConstraints().withAttendees()) {
                final String agent = attendee.getAgent();
                if (agent != null) {
                    if (attendee.getResponseStatus() != RESPONSE_STATUS.declined) {
                        syncEvent(agent);
                    } else {
                        clearAttendee(agent);
                    }
                }
            }

            activity = getActivity();
            final String updatedAfter = activity.withStatus().getUpdated();

            changed = !updatedBefore.equals(updatedAfter);
        }

        return changed;
    }

    /**
     * Schedule or re-schedule the meeting. Synchronize the events, retrieve
     * busy profiles, re-schedule the event.
     */
    public void update() {
        // TODO: optimize the update method
        LOG.info("update started");

        // stop running tasks
        stopAutoUpdate();

        clearIssues();

        // synchronize the events
        boolean changedEvent = syncEvents();
        if (changedEvent) {
            syncEvents();
        }

        // Check if the activity is finished
        // If not, schedule a new update task. Else we are done
        Activity activity = getActivity();
        final String start = (activity != null) ? activity.withStatus().getStart() : null;
        final String updated = (activity != null) ? activity.withStatus().getUpdated() : null;
        boolean isFinished = false;
        if (start != null && (new DateTime(start)).isBefore(DateTime.now())) {
            // start of the event is in the past
            isFinished = true;
            if (updated != null && (new DateTime(updated)).isAfter(new DateTime(start))) {
                // if changed after the last planned start time, then it is
                // updated afterwards, so do not mark as finished
                isFinished = false;
            }
        }
        if (activity != null && !isFinished) {
            // not yet finished. Reschedule the activity
            updateBusyIntervals();

            final boolean changedConstraints = applyConstraints();
            final boolean rescheduled = scheduleActivity();

            if (changedConstraints || rescheduled) {
                changedEvent = syncEvents();
                if (changedEvent) {
                    syncEvents();
                }
            }

            // TODO: not so nice adjusting the activityStatus here this way
            activity = getActivity();
            if (activity.withStatus().getActivityStatus() != Status.ACTIVITY_STATUS.error) {
                // store status of a activity as "planned"
                activity.withStatus().setActivityStatus(Status.ACTIVITY_STATUS.planned);
                getState().put("activity", activity);
            }

            startAutoUpdate();
        } else {
            // store status of a activity as "executed"
            activity.withStatus().setActivityStatus(Status.ACTIVITY_STATUS.executed);
            getState().put("activity", activity);

            LOG.info("The activity is over, my work is done. Goodbye world.");
        }
    }

    /**
     * Get the timestamp rounded to the next half hour
     * 
     * @return
     */
    private DateTime getNextHalfHour() {
        DateTime next = DateTime.now();
        next = next.minusMillis(next.getMillisOfSecond());
        next = next.minusSeconds(next.getSecondOfMinute());

        if (next.getMinuteOfHour() > 30) {
            next = next.minusMinutes(next.getMinuteOfHour());
            next = next.plusMinutes(60);
        } else {
            next = next.minusMinutes(next.getMinuteOfHour());
            next = next.plusMinutes(30);
        }

        return next;
    }

    /**
     * Schedule the meeting based on currently known event status, infeasible
     * intervals, and preferences
     * 
     * @return rescheduled Returns true if the activity has been rescheduled
     *         When rescheduled, events must be synchronized again with
     *         syncEvents.
     */
    private boolean scheduleActivity() {
        LOG.info("scheduleActivity started"); // TODO: cleanup
        final State state = getState();
        final Activity activity = state.get("activity", Activity.class);
        if (activity == null) {
            return false;
        }

        // read planned start and end from the activity
        DateTime activityStart = null;
        if (activity.withStatus().getStart() != null) {
            activityStart = new DateTime(activity.withStatus().getStart());
        }
        DateTime activityEnd = null;
        if (activity.withStatus().getEnd() != null) {
            activityEnd = new DateTime(activity.withStatus().getEnd());
        }
        Interval activityInterval = null;
        if (activityStart != null && activityEnd != null) {
            activityInterval = new Interval(activityStart, activityEnd);
        }

        // calculate solutions
        final List<Weight> solutions = calculateSolutions();
        if (solutions.size() > 0) {
            // there are solutions. yippie!
            final Weight solution = solutions.get(0);
            if (activityInterval == null || !solution.getInterval().equals(activityInterval)) {
                // interval is changed, save new interval
                final Status status = activity.withStatus();
                status.setStart(solution.getStart().toString());
                status.setEnd(solution.getEnd().toString());
                status.setActivityStatus(Status.ACTIVITY_STATUS.planned);
                status.setUpdated(DateTime.now().toString());
                state.put("activity", activity);
                // TODO: cleanup logging
                LOG.info("Activity replanned at " + solution.toString());
                try {
                    // TODO: cleanup
                    LOG.info("Replanned activity: " + JOM.getInstance().writeValueAsString(activity));
                } catch (final Exception e) {
                }
                return true;
            }
            // planning did not change. nothing to do.
        } else {
            if (activityStart != null || activityEnd != null) {
                // no solution
                final Issue issue = new Issue();
                issue.setCode(Issue.NO_PLANNING);
                issue.setType(Issue.TYPE.error);
                issue.setMessage("No free interval found for the meeting");
                issue.setTimestamp(DateTime.now().toString());
                // TODO: generate hints
                addIssue(issue);

                final Status status = activity.withStatus();
                status.setStart(null);
                status.setEnd(null);
                status.setActivityStatus(Status.ACTIVITY_STATUS.error);
                status.setUpdated(DateTime.now().toString());
                state.put("activity", activity);
                LOG.info(issue.getMessage()); // TODO: cleanup logging
                return true;
            }
            // planning did not change (no solution was already the case)
        }

        return false;
    }

    /**
     * Calculate all feasible intervals with their preference weight, based on
     * the event status, stored infeasible intervals, and preferred intervals.
     * If there are no solutions, an empty array is returned.
     * 
     * @return solutions
     */
    private List<Weight> calculateSolutions() {
        LOG.info("calculateSolutions started"); // TODO: cleanup

        final State state = getState();
        final List<Weight> solutions = new ArrayList<Weight>();

        // get the activity
        final Activity activity = state.get("activity", Activity.class);
        if (activity == null) {
            return solutions;
        }

        // get infeasible intervals
        ArrayList<Interval> infeasible = state.get("infeasible", new TypeUtil<ArrayList<Interval>>() {
        });
        if (infeasible == null) {
            infeasible = new ArrayList<Interval>();
        }

        // get preferred intervals
        List<Weight> preferred = state.get("preferred", new TypeUtil<ArrayList<Weight>>() {
        });
        if (preferred == null) {
            preferred = new ArrayList<Weight>();
        }

        // get the duration of the activity
        final Long durationLong = activity.withConstraints().withTime().getDuration();
        Duration duration = null;
        if (durationLong != null) {
            duration = new Duration(durationLong);
        } else {
            // TODO: give error when duration is not defined?
            duration = Duration.standardHours(1);
        }

        // check interval at next half hour
        final DateTime firstTimeslot = getNextHalfHour();
        Interval test = new Interval(firstTimeslot, firstTimeslot.plus(duration));
        testInterval(infeasible, preferred, test, solutions);

        // loop over all infeasible intervals
        for (final Interval i : infeasible) {
            // test timeslot left from the infeasible interval
            test = new Interval(i.getStart().minus(duration), i.getStart());
            testInterval(infeasible, preferred, test, solutions);

            // test timeslot right from the infeasible interval
            test = new Interval(i.getEnd(), i.getEnd().plus(duration));
            testInterval(infeasible, preferred, test, solutions);
        }

        // loop over all preferred intervals
        for (final Weight w : preferred) {
            // test timeslot left from the start of the preferred interval
            test = new Interval(w.getStart().minus(duration), w.getStart());
            testInterval(infeasible, preferred, test, solutions);

            // test timeslot right from the start of the preferred interval
            test = new Interval(w.getStart(), w.getStart().plus(duration));
            testInterval(infeasible, preferred, test, solutions);

            // test timeslot left from the end of the preferred interval
            test = new Interval(w.getEnd().minus(duration), w.getEnd());
            testInterval(infeasible, preferred, test, solutions);

            // test timeslot right from the end of the preferred interval
            test = new Interval(w.getEnd(), w.getEnd().plus(duration));
            testInterval(infeasible, preferred, test, solutions);
        }

        // order the calculated feasible timeslots by weight, from highest to
        // lowest. In case of equals weights, the timeslots are ordered by
        // start date
        class WeightComparator implements Comparator<Weight> {
            @Override
            public int compare(final Weight a, final Weight b) {
                if (a.getWeight() != null && b.getWeight() != null) {
                    final int cmp = Double.compare(a.getWeight(), b.getWeight());
                    if (cmp == 0) {
                        return a.getStart().compareTo(b.getStart());
                    } else {
                        return -cmp;
                    }
                }
                return 0;
            }
        }
        final WeightComparator comparator = new WeightComparator();
        Collections.sort(solutions, comparator);

        // remove duplicates
        int i = 1;
        while (i < solutions.size()) {
            if (solutions.get(i).equals(solutions.get(i - 1))) {
                solutions.remove(i);
            } else {
                i++;
            }
        }

        return solutions;
    }

    /**
     * Test if given interval is feasible. If so, calculate the preference
     * weight and add it to the provided array with solutions
     * 
     * @param infeasible
     * @param preferred
     * @param test
     * @param solutions
     */
    private void testInterval(final List<Interval> infeasible, final List<Weight> preferred, final Interval test,
            final List<Weight> solutions) {
        final boolean feasible = calculateFeasible(infeasible, test);
        if (feasible) {
            final double weight = calculatePreference(preferred, test);
            solutions.add(new Weight(test, weight));
        }
    }

    /**
     * Start automatic updating
     * The interval of the update task depends on the timestamp the activity
     * is last updated. When recently updated, the interval is smaller.
     * interval is minimum 10 sec and maximum 1 hour.
     */
    public void startAutoUpdate() {
        final State state = getState();
        final Activity activity = getActivity();

        // determine the interval (1 hour by default)
        final long TEN_SECONDS = 10 * 1000;
        final long ONE_HOUR = 60 * 60 * 1000;
        long interval = ONE_HOUR; // default is 1 hour
        if (activity != null) {
            final String updated = activity.withStatus().getUpdated();
            if (updated != null) {
                final DateTime dateUpdated = new DateTime(updated);
                final DateTime now = DateTime.now();
                interval = new Interval(dateUpdated, now).toDurationMillis();
            }
        }
        if (interval < TEN_SECONDS) {
            interval = TEN_SECONDS;
        }
        if (interval > ONE_HOUR) {
            interval = ONE_HOUR;
        }

        // stop any running task
        stopAutoUpdate();

        // schedule an update task and store the task id
        final JSONRequest request = new JSONRequest("update", null);
        final String task = getScheduler().createTask(request, interval);
        state.put("updateTask", task);

        LOG.info("Auto update started. Interval = " + interval + " milliseconds");
    }

    /**
     * Stop automatic updating.
     */
    public void stopAutoUpdate() {
        final State state = getState();

        final String task = state.get("updateTask", String.class);
        if (task != null) {
            getScheduler().cancelTask(task);
            state.remove("updateTask");
        }

        LOG.info("Auto update stopped");
    }

    /**
     * Convert a calendar event into an activity
     * 
     * @param event
     * @return activity
     */
    private Activity convertEventToActivity(final ObjectNode event) {
        final Activity activity = new Activity();

        // agent
        URI agent = null;
        if (event.has("agent")) {
            agent = URI.create(event.get("agent").asText());
        }
        activity.setAgent(agent);

        // summary
        String summary = null;
        if (event.has("summary")) {
            summary = event.get("summary").asText();
        }
        activity.setSummary(summary);

        // description
        String description = null;
        if (event.has("description")) {
            description = event.get("description").asText();
        }
        activity.setDescription(description);

        // updated
        String updated = null;
        if (event.has("updated")) {
            updated = event.get("updated").asText();
        }
        activity.withStatus().setUpdated(updated);

        // start
        String start = null;
        if (event.with("start").has("dateTime")) {
            start = event.with("start").get("dateTime").asText();
        }
        activity.withStatus().setStart(start);

        // end
        String end = null;
        if (event.with("end").has("dateTime")) {
            end = event.with("end").get("dateTime").asText();
        }
        activity.withStatus().setEnd(end);

        // duration
        if (start != null && end != null) {
            final Interval interval = new Interval(new DateTime(start), new DateTime(end));
            final Long duration = interval.toDurationMillis();
            activity.withConstraints().withTime().setDuration(duration);
        }

        // location
        String location = null;
        if (event.has("location")) {
            location = event.get("location").asText();
        }
        activity.withConstraints().withLocation().setSummary(location);

        return activity;
    }

    /**
     * Merge an activity into an event
     * All fields that are in the event will be left as they are
     * 
     * @param event
     * @param activity
     */
    private void mergeActivityIntoEvent(final ObjectNode event, final Activity activity) {
        // merge static information
        event.put("agent", activity.getAgent().toASCIIString());
        event.put("summary", activity.getSummary());
        event.put("description", activity.getDescription());

        // / merge status information
        final Status status = activity.withStatus();
        event.put("updated", status.getUpdated());
        event.with("start").put("dateTime", status.getStart());
        event.with("end").put("dateTime", status.getEnd());
        event.put("location", status.withLocation().getSummary());
    }

    /**
     * Retrieve all current issues. If there are no issues, an empty array
     * is returned
     * 
     * @return the issues
     */
    public ArrayList<Issue> getIssues() {
        ArrayList<Issue> issues = getState().get("issues", new TypeUtil<ArrayList<Issue>>() {
        });
        if (issues == null) {
            issues = new ArrayList<Issue>();
        }
        return issues;
    }

    /**
     * Remove all issues
     */
    private void clearIssues() {
        getState().remove("issues");
    }

    /**
     * Add an issue to the issue list
     * The issue will trigger an event
     * 
     * @param issue
     */
    private void addIssue(final Issue issue) {
        final ArrayList<Issue> issues = getIssues();
        issues.add(issue);
        getState().put("issues", issues);

        // trigger an error event
        try {
            final String event = issue.getType().toString();
            final ObjectNode data = JOM.createObjectNode();
            data.put("issue", JOM.getInstance().convertValue(issue, ObjectNode.class));
            final ObjectNode params = JOM.createObjectNode();
            params.put("description", issue.getMessage());
            params.put("data", data);
            getEventsFactory().trigger(event, params);
        } catch (final Exception e) {
            LOG.log(Level.WARNING, "", e);
        }
    }

    /**
     * Create an issue with type, code, and message
     * timestamp will be set to NOW
     * 
     * @param type
     * @param code
     * @param message
     */
    private void addIssue(final TYPE type, final Integer code, final String message) {
        final Issue issue = new Issue();
        issue.setType(type);
        issue.setCode(code);
        issue.setMessage(message);
        issue.setTimestamp(DateTime.now().toString());
        addIssue(issue);
    }

    /**
     * Retrieve the data of a single calendar agent from the state
     * 
     * @param agentUrl
     * @return data returns the calendar data. If not available, a new, empty
     *         CalendarAgentData is returned.
     */
    // TODO: create some separate AgentData handling class, instead of methods
    // in MeetingAgent
    private AgentData getAgentData(final String agentUrl) {
        final HashMap<String, AgentData> calendarAgents = getState().get("calendarAgents",
                new TypeUtil<HashMap<String, AgentData>>() {
                });

        if (calendarAgents != null && calendarAgents.containsKey(agentUrl)) {
            return calendarAgents.get(agentUrl);
        }
        return new AgentData();
    }

    /**
     * Put data for a calendar agent into the state
     * 
     * @param agentUrl
     * @param data
     */
    private void putAgentData(final String agentUrl, final AgentData data) {
        final State state = getState();
        Map<String, AgentData> calendarAgents = getState().get("calendarAgents",
                new TypeUtil<HashMap<String, AgentData>>() {
                });

        if (calendarAgents == null) {
            calendarAgents = new HashMap<String, AgentData>();
        }

        calendarAgents.put(agentUrl, data);
        state.put("calendarAgents", calendarAgents);
    }

    /**
     * Remove a calendar agent data from the state
     * 
     * @param agent
     * @param data
     */
    private void removeAgentData(final String agent) {
        final State state = getState();
        final Map<String, AgentData> calendarAgents = getState().get("calendarAgents",
                new TypeUtil<HashMap<String, AgentData>>() {
                });
        if (calendarAgents != null && calendarAgents.containsKey(agent)) {
            calendarAgents.remove(agent);
            state.put("calendarAgents", calendarAgents);
        }
    }

    /**
     * Retrieve the busy intervals of a calendar agent from the state
     * 
     * @param agent
     * @return busy returns busy intervals, or null if not available
     */
    private List<Interval> getAgentBusy(final String agent) {
        final AgentData data = getAgentData(agent);
        return data.busy;
    }

    /**
     * Put the busy intervals for a calendar agent into the state
     * 
     * @param agent
     * @param busy
     */
    private void putAgentBusy(final String agent, final List<Interval> busy) {
        final AgentData data = getAgentData(agent);
        data.busy = busy;
        putAgentData(agent, data);
    }

    /**
     * Retrieve calendar event from calendaragent
     * 
     * @param agent
     * @return event Calendar event, or null if not found
     */
    private ObjectNode getEvent(final String agent) {
        ObjectNode event = null;
        final String eventId = getAgentData(agent).eventId;
        if (eventId != null) {
            final ObjectNode params = JOM.createObjectNode();
            params.put("eventId", eventId);
            try {
                event = send(URI.create(agent), "getEvent", params, ObjectNode.class);
            } catch (final JSONRPCException e) {
                if (e.getCode() == 404) {
                    // event was deleted by the user.
                    final Activity activity = getState().get("activity", Activity.class);
                    final Attendee attendee = activity.withConstraints().withAttendee(agent);
                    attendee.setResponseStatus(RESPONSE_STATUS.declined);
                    getState().put("activity", activity);

                    clearAttendee(agent); // TODO: seems not to work
                } else {
                    LOG.log(Level.WARNING, "", e);
                }
            } catch (final Exception e) {
                addIssue(TYPE.warning, Issue.EXCEPTION, e.getMessage());
                LOG.log(Level.WARNING, "", e);
            }
        }
        return event;
    }

    // TODO: comment
    private boolean equalsDateTime(final String a, final String b) {
        if (a != null && b != null) {
            return new DateTime(a).equals(new DateTime(b));
        }
        if (a == null && b == null) {
            return false;
        }

        return true;
    }

    /**
     * Synchronize the event with given calendar agent
     * 
     * @param agent
     */
    // TODO: the method syncEvent has grown to large. split it up
    private void syncEvent(@Name("agent") final String agent) {
        LOG.info("syncEvent started for agent " + agent);
        final State state = getState();

        // retrieve event from calendar agent
        ObjectNode event = getEvent(agent);
        if (event == null) {
            event = JOM.createObjectNode();
        }
        final Activity eventActivity = convertEventToActivity(event);

        // verify all kind of stuff
        final Activity activity = state.get("activity", Activity.class);
        if (activity == null) {
            return; // oops no activity at all
        }
        if (activity.withStatus().getStart() == null || activity.withStatus().getEnd() == null) {
            return; // activity is not yet planned. cancel synchronization
        }
        final Attendee attendee = activity.withConstraints().getAttendee(agent);
        if (attendee == null) {
            return; // unknown attendee
        }
        if (attendee.getResponseStatus() == Attendee.RESPONSE_STATUS.declined) {
            // attendee does not want to attend
            clearAttendee(agent);
            return;
        }

        // check if the activity or the retrieved event is changed since the
        // last synchronization
        final AgentData agentData = getAgentData(agent);
        final boolean activityChanged = !equalsDateTime(agentData.activityUpdated,
                activity.withStatus().getUpdated());
        final boolean eventChanged = !equalsDateTime(agentData.eventUpdated,
                eventActivity.withStatus().getUpdated());
        final boolean changed = activityChanged || eventChanged;
        if (changed && activity.isNewerThan(eventActivity)) {
            // activity is updated (event is out-dated or not yet existing)

            // merge the activity into the event
            mergeActivityIntoEvent(event, activity);

            // TODO: if attendee cannot attend (=optional or declined), show
            // this somehow in the event

            // save the event
            final ObjectNode params = JOM.createObjectNode();
            params.put("event", event);
            try {
                // TODO: only update/create the event when the attendee
                // is not optional or is available at the planned time
                final String method = event.has("id") ? "updateEvent" : "createEvent";
                final ObjectNode updatedEvent = send(URI.create(agent), method, params, ObjectNode.class);

                // update the agent data
                agentData.eventId = updatedEvent.get("id").asText();
                agentData.eventUpdated = updatedEvent.get("updated").asText();
                agentData.activityUpdated = activity.withStatus().getUpdated();
                putAgentData(agent, agentData);
            } catch (final JSONRPCException e) {
                addIssue(TYPE.warning, Issue.JSONRPCEXCEPTION, e.getMessage());
                LOG.log(Level.WARNING, "", e);
            } catch (final Exception e) {
                addIssue(TYPE.warning, Issue.EXCEPTION, e.getMessage());
                LOG.log(Level.WARNING, "", e);
            }
        } else if (changed) {
            // event is updated (activity is out-dated or both have the same
            // updated timestamp)

            // if start is changed, add this as preferences to the constraints
            if (!equalsDateTime(activity.withStatus().getStart(), eventActivity.withStatus().getStart())) {
                /*
                 * TODO: store the old interval as undesired?
                 * String oldStart = activity.withStatus().getStart();
                 * String oldEnd = activity.withStatus().getEnd();
                 * if (oldStart != null && oldEnd != null) {
                 * Preference undesired = new Preference ();
                 * undesired.setStart(oldStart);
                 * undesired.setEnd(oldEnd);
                 * undesired.setWeight(WEIGHT_UNDESIRED_INTERVAL);
                 * activity.getConstraints().getTime().addPreference(undesired);
                 * }
                 */

                // store the new interval as preferred
                final String newStart = eventActivity.withStatus().getStart();
                final String newEnd = eventActivity.withStatus().getEnd();
                if (newStart != null && newEnd != null) {
                    final Preference preferred = new Preference();
                    preferred.setStart(newStart);
                    preferred.setEnd(newEnd);
                    preferred.setWeight(WEIGHT_PREFERRED_INTERVAL);

                    // overwrite other preferences with this new preference
                    // TODO: all preferences are overwritten for now. Behavior
                    // should be changed.
                    final List<Preference> preferences = new ArrayList<Preference>();
                    preferences.add(preferred);
                    activity.getConstraints().getTime().setPreferences(preferences);

                    // activity.getConstraints().getTime().addPreference(preferred);
                }
            }
            // else events are in sync, nothing to do

            // update the activity
            activity.merge(eventActivity);
            state.put("activity", activity);

            // update the agent data
            agentData.eventId = event.get("id").asText();
            agentData.eventUpdated = event.get("updated").asText();
            agentData.activityUpdated = activity.withStatus().getUpdated();
            putAgentData(agent, agentData);
        } else {
            // activity and eventActivity have the same updated timestamp
            // nothing to do.
            LOG.info("event and activity are in sync"); // TODO: cleanup
        }
    }

    /**
     * Update the busy intervals of all attendees, and merge the results
     */
    private void updateBusyIntervals() {
        final Activity activity = getActivity();
        if (activity != null) {
            final List<Attendee> attendees = activity.withConstraints().withAttendees();
            for (final Attendee attendee : attendees) {
                final String agent = attendee.getAgent();
                if (attendee.getResponseStatus() != RESPONSE_STATUS.declined) {
                    updateBusyInterval(agent);
                }
            }
        }

        mergeTimeConstraints();
    }

    /**
     * Merge the busy intervals of all attendees, and the preferred intervals
     */
    private void mergeTimeConstraints() {
        final ArrayList<Interval> infeasibleIntervals = new ArrayList<Interval>();
        final ArrayList<Weight> preferredIntervals = new ArrayList<Weight>();

        final Activity activity = getActivity();
        if (activity != null) {
            // read and merge the stored busy intervals of all attendees
            for (final Attendee attendee : activity.withConstraints().withAttendees()) {
                final String agent = attendee.getAgent();
                if (attendee.getResponseStatus() != RESPONSE_STATUS.declined) {
                    if (new Boolean(true).equals(attendee.getOptional())) {
                        // This attendee is optional.
                        // Add its busy intervals to the soft constraints
                        final List<Interval> attendeeBusy = getAgentBusy(agent);
                        if (attendeeBusy != null) {
                            for (final Interval i : attendeeBusy) {
                                final Weight wi = new Weight(i.getStart(), i.getEnd(),
                                        WEIGHT_BUSY_OPTIONAL_ATTENDEE);

                                preferredIntervals.add(wi);
                            }
                        }
                    } else {
                        // this attendee is required.
                        // Add its busy intervals to the hard constraints
                        final List<Interval> attendeeBusy = getAgentBusy(agent);
                        if (attendeeBusy != null) {
                            infeasibleIntervals.addAll(attendeeBusy);
                        }
                    }
                }
                // else This attendee declined. Ignore this attendees busy
                // interval
            }

            // read the time preferences and add them to the soft constraints
            final List<Preference> preferences = activity.withConstraints().withTime().withPreferences();
            for (final Preference p : preferences) {
                if (p != null) {
                    final Weight wi = new Weight(new DateTime(p.getStart()), new DateTime(p.getEnd()),
                            p.getWeight());

                    preferredIntervals.add(wi);
                }
            }
        }

        // add office hours profile to the soft constraints
        // TODO: don't include (hardcoded) office hours here, should be handled
        // by a PersonalAgent
        final DateTime timeMin = DateTime.now();
        final DateTime timeMax = timeMin.plusDays(LOOK_AHEAD_DAYS);
        final List<Interval> officeHours = IntervalsUtil.getOfficeHours(timeMin, timeMax);
        for (final Interval i : officeHours) {
            final Weight wi = new Weight(i, WEIGHT_OFFICE_HOURS);
            preferredIntervals.add(wi);
        }

        // add delay penalties to the soft constraints
        final DateTime now = DateTime.now();
        final MutableDateTime d = new MutableDateTime(now.getYear(), now.getMonthOfYear(), now.getDayOfMonth(), 0,
                0, 0, 0);
        for (int i = 0; i <= LOOK_AHEAD_DAYS; i++) {
            final DateTime start = d.toDateTime();
            final DateTime end = start.plusDays(1);
            final Weight wi = new Weight(start, end, WEIGHT_DELAY_PER_DAY * i);
            preferredIntervals.add(wi);
            d.addDays(1);
        }

        // order and store the aggregated lists with intervals
        IntervalsUtil.order(infeasibleIntervals);
        getState().put("infeasible", infeasibleIntervals);
        WeightsUtil.order(preferredIntervals);
        getState().put("preferred", preferredIntervals);
    }

    /**
     * Calculate the average preference for given interval.
     * The method aggregates over all stored preferences
     * Default preference is 0.
     * 
     * @param preferredIntervals
     *            list with intervals ordered by start
     * @param test
     *            test interval
     * @return preference
     */
    private double calculatePreference(final List<Weight> preferredIntervals, final Interval test) {
        double preference = 0;

        for (final Weight interval : preferredIntervals) {
            final Interval overlap = test.overlap(interval.getInterval());
            if (overlap != null) {
                final Double weight = interval.getWeight();
                if (weight != null) {
                    final double durationCheck = test.toDurationMillis();
                    final double durationOverlap = overlap.toDurationMillis();
                    final double avgWeight = (durationOverlap / durationCheck) * weight;
                    preference += avgWeight;
                }
            }

            if (interval.getStart().isAfter(test.getEnd())) {
                // as the list is ordered, we can exit as soon as we have an
                // interval which starts after the wanted interval.
                break;
            }
        }

        return preference;
    }

    /**
     * Calculate whether given interval is feasible (i.e. does not overlap with
     * any of the infeasible intervals, and is not in the past)
     * 
     * @param infeasibleIntervals
     *            list with intervals ordered by start
     * @param timeMin
     * @param timeMax
     * @return feasible
     */
    private boolean calculateFeasible(final List<Interval> infeasibleIntervals, final Interval test) {
        if (test.getStart().isBeforeNow()) {
            // interval starts in the past
            return false;
        }

        for (final Interval interval : infeasibleIntervals) {
            if (test.overlaps(interval)) {
                return false;
            }
            if (interval.getStart().isAfter(test.getEnd())) {
                // as the list is ordered, we can exit as soon as we have an
                // interval which starts after the wanted interval.
                break;
            }
        }

        return true;
    }

    /**
     * Retrieve the feasible and preferred intervals.
     * 
     * @return the intervals
     */
    // TODO: remove this temporary method
    public ObjectNode getIntervals() {
        final ObjectNode intervals = JOM.createObjectNode();

        final List<Interval> infeasible = getState().get("infeasible", new TypeUtil<ArrayList<Interval>>() {
        });
        final List<Weight> preferred = getState().get("preferred", new TypeUtil<ArrayList<Weight>>() {
        });
        final List<Weight> solutions = calculateSolutions();

        // merge the intervals
        List<Interval> mergedInfeasible = null;
        List<Weight> mergedPreferred = null;
        if (infeasible != null) {
            mergedInfeasible = IntervalsUtil.merge(infeasible);
        }
        if (preferred != null) {
            mergedPreferred = WeightsUtil.merge(preferred);
        }

        if (infeasible != null) {
            final ArrayNode arr = JOM.createArrayNode();
            for (final Interval interval : infeasible) {
                final ObjectNode o = JOM.createObjectNode();
                o.put("start", interval.getStart().toString());
                o.put("end", interval.getEnd().toString());
                arr.add(o);
            }
            intervals.put("infeasible", arr);
        }

        if (preferred != null) {
            final ArrayNode arr = JOM.createArrayNode();
            for (final Weight weight : preferred) {
                final ObjectNode o = JOM.createObjectNode();
                o.put("start", weight.getStart().toString());
                o.put("end", weight.getEnd().toString());
                o.put("weight", weight.getWeight());
                arr.add(o);
            }
            intervals.put("preferred", arr);
        }

        if (solutions != null) {
            final ArrayNode arr = JOM.createArrayNode();
            for (final Weight weight : solutions) {
                final ObjectNode o = JOM.createObjectNode();
                o.put("start", weight.getStart().toString());
                o.put("end", weight.getEnd().toString());
                o.put("weight", weight.getWeight());
                arr.add(o);
            }
            intervals.put("solutions", arr);
        }

        if (mergedInfeasible != null) {
            final ArrayNode arr = JOM.createArrayNode();
            for (final Interval i : mergedInfeasible) {
                final ObjectNode o = JOM.createObjectNode();
                o.put("start", i.getStart().toString());
                o.put("end", i.getEnd().toString());
                arr.add(o);
            }
            intervals.put("mergedInfeasible", arr);
        }

        if (mergedPreferred != null) {
            final ArrayNode arr = JOM.createArrayNode();
            for (final Weight wi : mergedPreferred) {
                final ObjectNode o = JOM.createObjectNode();
                o.put("start", wi.getStart().toString());
                o.put("end", wi.getEnd().toString());
                o.put("weight", wi.getWeight());
                arr.add(o);
            }
            intervals.put("mergedPreferred", arr);
        }

        return intervals;
    }

    /**
     * Retrieve the busy intervals of a calendar agent
     * 
     * @param agent
     */
    private void updateBusyInterval(@Name("agent") final String agent) {
        try {
            // create parameters with the boundaries of the interval to be
            // retrieved
            final ObjectNode params = JOM.createObjectNode();
            final DateTime timeMin = DateTime.now();
            final DateTime timeMax = timeMin.plusDays(LOOK_AHEAD_DAYS);
            params.put("timeMin", timeMin.toString());
            params.put("timeMax", timeMax.toString());

            // exclude the event managed by this agent from the busy intervals
            final String eventId = getAgentData(agent).eventId;
            if (eventId != null) {
                final ArrayNode excludeEventIds = JOM.createArrayNode();
                excludeEventIds.add(eventId);
                params.put("excludeEventIds", excludeEventIds);
            }

            // get the busy intervals from the agent
            final ArrayNode array = send(URI.create(agent), "getBusy", params, ArrayNode.class);

            // convert from ArrayNode to List
            final List<Interval> busy = new ArrayList<Interval>();
            for (int i = 0; i < array.size(); i++) {
                final ObjectNode obj = (ObjectNode) array.get(i);
                final String start = obj.has("start") ? obj.get("start").asText() : null;
                final String end = obj.has("end") ? obj.get("end").asText() : null;
                busy.add(new Interval(new DateTime(start), new DateTime(end)));
            }

            // store the interval in the state
            putAgentBusy(agent, busy);

        } catch (final JSONRPCException e) {
            addIssue(TYPE.warning, Issue.JSONRPCEXCEPTION, e.getMessage());
            LOG.log(Level.WARNING, "", e);
        } catch (final Exception e) {
            addIssue(TYPE.warning, Issue.EXCEPTION, e.getMessage());
            LOG.log(Level.WARNING, "", e);
        }
    }

    /**
     * Delete everything of the agent.
     */
    @Override
    public void onDelete() {
        clear();

        // super class will delete the state
        super.onDelete();
    }

    /**
     * Clear the stored activity, and remove events from attendees.
     */
    @Access(AccessType.UNAVAILABLE)
    public void clear() {
        final Activity activity = getActivity();

        if (activity != null) {
            final List<Attendee> attendees = activity.withConstraints().withAttendees();
            for (final Attendee attendee : attendees) {
                final String agent = attendee.getAgent();
                if (agent != null) {
                    clearAttendee(agent);
                }
            }
        }

        // stop auto update timer (if any)
        stopAutoUpdate();
    }

    /**
     * Clear an event from given agent
     * 
     * @param agent
     */
    private void clearAttendee(@Name("agent") final String agent) {
        final AgentData data = getAgentData(agent);
        if (data != null) {
            try {
                if (data.eventId != null) {
                    final ObjectNode params = JOM.createObjectNode();
                    params.put("eventId", data.eventId);
                    send(URI.create(agent), "deleteEvent", params);
                    data.eventId = null;
                }
            } catch (final JSONRPCException e) {
                if (e.getCode() == 404) {
                    // event was already deleted. fine!
                    data.eventId = null;
                } else {
                    LOG.log(Level.WARNING, "", e);
                }
            } catch (final Exception e) {
                LOG.log(Level.WARNING, "", e);
            }

            if (data.eventId == null) {
                removeAgentData(agent);
                LOG.info("clearAttendee " + agent + " cleared");
            }
        }
    }

    // TODO: cleanup this temporary method
    /**
     * Gets the office hours.
     * 
     * @param timeMin
     *            the time min
     * @param timeMax
     *            the time max
     * @return the office hours
     */
    public ArrayNode getOfficeHours(@Name("timeMin") final String timeMin, @Name("timeMax") final String timeMax) {
        final List<Interval> available = IntervalsUtil.getOfficeHours(new DateTime(timeMin), new DateTime(timeMax));

        // convert to JSON array
        final ArrayNode array = JOM.createArrayNode();
        for (final Interval interval : available) {
            final ObjectNode obj = JOM.createObjectNode();
            obj.put("start", interval.getStart().toString());
            obj.put("end", interval.getEnd().toString());
            array.add(obj);
        }
        return array;
    }

    /*
     * Get the first url filtered by a specific protocol
     * 
     * @param protocol For example "http"
     * 
     * @return url Returns url or null if not found
     */
    protected URI getFirstUrl(final String protocol) {
        final List<String> urls = getUrls();

        for (final String url : urls) {
            if (url.startsWith(protocol + ":")) {
                return URI.create(url);
            }
        }

        return null;
    }

    /*
     * (non-Javadoc)
     * 
     * @see com.almende.eve.agent.Agent#getDescription()
     */
    @Override
    public String getDescription() {
        return "A MeetingAgent can dynamically plan and manage a meeting.";
    }

    /*
     * (non-Javadoc)
     * 
     * @see com.almende.eve.agent.Agent#getVersion()
     */
    @Override
    public String getVersion() {
        return "0.1";
    }
}