/* * Copyright (C) 2012 McEvoy Software Ltd * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package io.milton.mini.services; import io.milton.mini.utils.CalUtils; import io.milton.vfs.db.AttendeeRequest; import static io.milton.vfs.db.AttendeeRequest.PARTSTAT_ACCEPTED; import io.milton.vfs.db.BaseEntity; import io.milton.vfs.db.CalEvent; import java.io.*; import java.util.ArrayList; import java.util.Date; import net.fortuna.ical4j.data.CalendarBuilder; import net.fortuna.ical4j.data.CalendarOutputter; import net.fortuna.ical4j.data.ParserException; import net.fortuna.ical4j.model.*; import net.fortuna.ical4j.model.component.VEvent; import net.fortuna.ical4j.model.component.VTimeZone; import net.fortuna.ical4j.model.parameter.TzId; import net.fortuna.ical4j.model.property.ProdId; import net.fortuna.ical4j.model.property.Uid; import net.fortuna.ical4j.model.property.Version; import org.hibernate.Session; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.milton.vfs.db.Calendar; import io.milton.vfs.db.Profile; import io.milton.vfs.db.utils.SessionManager; import java.net.URI; import java.nio.charset.Charset; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Iterator; import java.util.List; import net.fortuna.ical4j.model.parameter.Cn; import net.fortuna.ical4j.model.parameter.PartStat; import net.fortuna.ical4j.model.parameter.Role; import net.fortuna.ical4j.model.parameter.Rsvp; import net.fortuna.ical4j.model.parameter.ScheduleStatus; import net.fortuna.ical4j.model.property.Attendee; import net.fortuna.ical4j.model.property.Description; import net.fortuna.ical4j.model.property.Method; import net.fortuna.ical4j.model.property.Organizer; import org.apache.commons.codec.digest.DigestUtils; /** * * @author brad */ public class CalendarService { private static final Logger log = LoggerFactory.getLogger(CalendarService.class); private String defaultColor = "blue"; public net.fortuna.ical4j.model.Calendar parse(InputStream icalContent) throws IOException, ParserException { CalendarBuilder builder = new CalendarBuilder(); net.fortuna.ical4j.model.Calendar cal = builder.build(icalContent); return cal; } /** * Called when an invitee responds to an invitation * * @param attendeeReq * @param cal * @return returns the user's event if a partstat is found for this user's invitation and the partstat is accepted */ public CalEvent processResponse(AttendeeRequest attendeeReq, Calendar userCalendar, net.fortuna.ical4j.model.Calendar cal, Session session) { VEvent ev = event(cal); for (Object p : ev.getProperties()) { if (p instanceof Attendee) { Attendee att = (Attendee) p; String attEmail = findEmail(att); if (attEmail != null) { if (attEmail.equals(attendeeReq.getAttendee().getEmail())) { // Found it, find the part-stat Parameter pPartStat = att.getParameter("PARTSTAT"); if (pPartStat != null) { attendeeReq.setParticipationStatus(pPartStat.getValue()); session.save(attendeeReq); if( attendeeReq.getParticipationStatus().equals(AttendeeRequest.PARTSTAT_DECLINED)) { // Declined, so remove event from attendee if previously created CalEvent e = attendeeReq.getAttendeeEvent(); if( e != null ) { log.info("delete event"); e.delete(session); } } else if( attendeeReq.getParticipationStatus().equals(AttendeeRequest.PARTSTAT_NEEDS_ACTION ) ) { CalEvent e = attendeeReq.getAttendeeEvent(); if( e != null ) { log.info("delete event"); e.delete(session); } } else { CalEvent e = attendeeReq.getAttendeeEvent(); if( e == null ) { log.info("add event"); Date now = new Date(); CalEvent orgEvent = attendeeReq.getOrganiserEvent(); e = userCalendar.add(attendeeReq.getName(), now); e.setAttendeeRequest(attendeeReq); copyProps(orgEvent, e); session.save(e); attendeeReq.setAttendeeEvent(e); session.save(attendeeReq); } return e; } } } } } } return null; } /** * Returns the updated ical text * * @param event * @param data * @param callback - called for any updated events, included attendee events * which have been generated on acceptance of invitations * @return - updated ical text */ public String update(CalEvent event, String data, UpdatedAttendeeCallback callback) { log.info("update: " + event.getName()); System.out.println(data); String ical = null; Session session = SessionManager.session(); try { ical = _update(event, data, callback, session); } catch (IOException | ParserException ex) { throw new RuntimeException(ex); } session.save(event); session.save(event.getCalendar()); return ical; } public Calendar createCalendar(BaseEntity owner, String newName) { Session session = SessionManager.session(); Calendar c = new Calendar(); c.setColor(defaultColor); c.setCreatedDate(new Date()); c.setName(newName); c.setBaseEntity(owner); session.save(c); return c; } public void delete(CalEvent event) { Session session = SessionManager.session(); if (event.getAttendeeRequest() != null) { event.getAttendeeRequest().setAttendeeEvent(null); session.save(event.getAttendeeRequest()); } session.flush(); List<AttendeeRequest> attendeeRequests = AttendeeRequest.findByOrganisorEvent(event, session); for (AttendeeRequest ar : attendeeRequests) { log.info("Delete AttendeeRequest: " + ar.getName()); session.delete(ar); session.flush(); CalEvent attendeeRequesst = ar.getAttendeeEvent(); if (attendeeRequesst != null) { delete(attendeeRequesst); } } session.flush(); log.info("delete event id: " + event.getId()); session.delete(event); session.flush(); } public void move(CalEvent event, Calendar destCalendar, String name) { Session session = SessionManager.session(); if (!name.equals(event.getName())) { event.setName(name); } Calendar sourceCal = event.getCalendar(); if (destCalendar != sourceCal) { sourceCal.getEvents().remove(event); event.setCalendar(destCalendar); if (destCalendar.getEvents() == null) { destCalendar.setEvents(new ArrayList<CalEvent>()); } destCalendar.getEvents().add(event); session.save(sourceCal); session.save(destCalendar); } } public void copy(CalEvent event, Calendar destCalendar, String name) { Session session = SessionManager.session(); if (destCalendar.getEvents() == null) { destCalendar.setEvents(new ArrayList<CalEvent>()); } CalEvent newEvent = new CalEvent(); newEvent.setCalendar(destCalendar); destCalendar.getEvents().add(newEvent); newEvent.setCreatedDate(new Date()); newEvent.setModifiedDate(new Date()); newEvent.setName(name); copyProps(event, newEvent); session.save(newEvent); } public void copyProps(CalEvent event, CalEvent newEvent) { newEvent.setDescription(event.getDescription()); newEvent.setEndDate(event.getEndDate()); newEvent.setStartDate(event.getStartDate()); newEvent.setSummary(event.getSummary()); newEvent.setTimezone(event.getTimezone()); } public void delete(Calendar calendar) { Session session = SessionManager.session(); session.delete(calendar); } public CalEvent createEvent(Calendar calendar, String newName, String icalData, UpdatedEventCallback callback) throws IOException { log.info("createEvent: newName=" + newName + " -- " + icalData); Session session = SessionManager.session(); Date now = new Date(); CalEvent e = calendar.add(newName, now); AttendeeRequest ar = AttendeeRequest.findByName(newName, session); if (icalData != null) { ByteArrayInputStream fin = new ByteArrayInputStream(icalData.getBytes("UTF-8")); CalendarBuilder builder = new CalendarBuilder(); net.fortuna.ical4j.model.Calendar cal4jCalendar; try { cal4jCalendar = builder.build(fin); } catch (IOException | ParserException ex) { throw new RuntimeException(ex); } boolean isAccept = ar != null; _setCalendar(cal4jCalendar, e, isAccept, session); session.save(e); if (callback != null) { String newIcal = formatIcal(cal4jCalendar); callback.updated(newIcal, e); } } // Check to see if we are accepting an attendeerequest, ie where name is the same if (ar != null) { log.info("found attendee request, so link"); e.setAttendeeRequest(ar); ar.setAttendeeEvent(e); ar.setAcknowledged(true); ar.setParticipationStatus(PARTSTAT_ACCEPTED); session.save(ar); } return e; } /** * When an organisor event has changed, update appropriate fields only on * attendee events * * @param attendeeEvent * @param event * @param calendar * @param session */ private void updateAttendeeEvent(AttendeeRequest ar, boolean needsReInvite, CalEvent attendeeEvent, CalEvent event, net.fortuna.ical4j.model.Calendar calendar, UpdatedAttendeeCallback callback, Session session) throws IOException { log.info("updateAttendeeEvent: " + event.getSummary()); attendeeEvent.setDescription(event.getDescription()); attendeeEvent.setModifiedDate(new Date()); attendeeEvent.setSummary(event.getSummary()); if (needsReInvite) { // delete the event and set partstat to NEEDS-ACTION callback.deleted(event); ar.setAttendeeEvent(null); ar.setParticipationStatus(AttendeeRequest.PARTSTAT_NEEDS_ACTION); ar.setAcknowledged(false); session.save(ar); attendeeEvent.delete(session); } else { session.save(attendeeEvent); callback.updated(attendeeEvent); } } /** * Add RSVP=True to attendees and return new ical data * * @param event * @param e * @param session */ public void setRsvps(VEvent event, CalEvent e, Session session) { log.info("setRsvps: " + event.getName()); for (Object o : event.getProperties()) { if (o instanceof Organizer) { Organizer org = (Organizer) o; String mail = org.getCalAddress().toString(); System.out.println("------------ org mail = " + mail); mail = mail.replace("mailto:", ""); Profile p = Profile.findByEmail(mail, session); if (p != null) { log.info("set org profile: " + p.getFormattedName()); e.setOrganisor(p); } else { e.setOrganisor(null); } session.save(e); // TODO } else if (o instanceof Attendee) { Attendee attendee = (Attendee) o; Iterator it = attendee.getParameters().iterator(); boolean rsvpFound = false; while (it.hasNext()) { Parameter p = (Parameter) it.next(); if (p.getName().equals(Rsvp.RSVP)) { rsvpFound = true; break; } } if (!rsvpFound) { attendee.getParameters().add(Rsvp.TRUE); } String attendeeEmail = attendee.getValue(); attendeeEmail = attendeeEmail.replace("mailto:", ""); log.info("Check/Create attendance record for: " + attendeeEmail); Profile p = Profile.findByEmail(attendeeEmail, SessionManager.session()); if (p != null) { log.info("Check create attendance for: " + p.getName()); Date now = new Date(); Parameter sa = attendee.getParameter("SCHEDULE-AGENT"); String scheduleAgent = null; if (sa != null) { scheduleAgent = sa.getValue(); } AttendeeRequest.checkCreate(p, e, now, scheduleAgent, session); } else { log.warn("Did not find user: " + attendeeEmail); } } } } public net.fortuna.ical4j.model.Calendar getCalendar(CalEvent calEvent) { net.fortuna.ical4j.model.Calendar calendar = new net.fortuna.ical4j.model.Calendar(); calendar.getProperties().add(new ProdId("-//milton.io//iCal4j 1.0//EN")); calendar.getProperties().add(Version.VERSION_2_0); //calendar.getProperties().add(CalScale.GREGORIAN); TimeZoneRegistry registry = TimeZoneRegistryFactory.getInstance().createRegistry(); String sTimezone = calEvent.getTimezone(); TimeZone timezone = null; if (sTimezone != null && sTimezone.length() > 0) { timezone = registry.getTimeZone(sTimezone); // Eg Pacific/Auckland } if (timezone == null) { timezone = registry.getTimeZone("Pacific/Auckland"); log.warn("Couldnt find timezone: " + sTimezone + ", using default: " + timezone); } VTimeZone tz = timezone.getVTimeZone(); calendar.getComponents().add(tz); net.fortuna.ical4j.model.DateTime start = CalUtils.toCalDateTime(calEvent.getStartDate(), timezone); net.fortuna.ical4j.model.DateTime finish = CalUtils.toCalDateTime(calEvent.getEndDate(), timezone); String summary = calEvent.getSummary(); VEvent vevent = new VEvent(start, finish, summary); //vevent.getProperties().add(new Uid(UUID.randomUUID().toString())); vevent.getProperties().add(new Uid(calEvent.getId().toString())); vevent.getProperties().add(tz.getTimeZoneId()); TzId tzParam = new TzId(tz.getProperties().getProperty(Property.TZID).getValue()); vevent.getProperties().getProperty(Property.DTSTART).getParameters().add(tzParam); Session session = SessionManager.session(); List<AttendeeRequest> attendees = AttendeeRequest.findByOrganisorEvent(calEvent, session); if (!attendees.isEmpty()) { if (calEvent.getOrganisor() != null) { Organizer organizer = new Organizer(URI.create("mailto:" + calEvent.getOrganisor().getEmail())); vevent.getProperties().add(organizer); } else { log.warn("There is no organisaor"); } } for (AttendeeRequest ar : attendees) { String email; StringBuilder sbCommonName = new StringBuilder(); String stat; if (ar.getAttendee() != null) { Profile p = ar.getAttendee(); sbCommonName.append(p.getFormattedName()); stat = "1.2"; // delivered ok email = p.getEmail(); } else { if (ar.getFirstName() != null) { sbCommonName.append(ar.getFirstName()); } if (ar.getSurName() != null) { sbCommonName.append(" ").append(ar.getFirstName()); } stat = "5.3"; // we dont deliver to non-system users at the moment email = ar.getMail(); } String cn = sbCommonName.toString().trim(); Attendee a = new Attendee(URI.create("mailto:" + email)); a.getParameters().add(Role.REQ_PARTICIPANT); a.getParameters().add(new Cn(cn)); Parameter ss = new ScheduleStatus(stat); a.getParameters().add(ss); if (ar.getParticipationStatus() == null) { ar.setParticipationStatus(AttendeeRequest.PARTSTAT_NEEDS_ACTION); } PartStat partStat = new PartStat(ar.getParticipationStatus()); a.getParameters().add(partStat); vevent.getProperties().add(a); } calendar.getComponents().add(vevent); return calendar; } public String getCalendarICal(CalEvent calEvent) { net.fortuna.ical4j.model.Calendar calendar = getCalendar(calEvent); return formatIcal(calendar); } public String getInviteICal(AttendeeRequest attendeeRequest) { net.fortuna.ical4j.model.Calendar calendar = getCalendar(attendeeRequest.getOrganiserEvent()); // Need to add method=request VEvent ev = event(calendar); ev.getProperties().add(Method.REQUEST); return formatIcal(calendar); } /** * Given a CalEvent which contains updated information, apply that to the * parsed ical text in the calendar object * * @param calEvent * @param calendar * @return */ public String updateIcalText(CalEvent calEvent, net.fortuna.ical4j.model.Calendar calendar) { TimeZoneRegistry registry = TimeZoneRegistryFactory.getInstance().createRegistry(); VEvent vevent = event(calendar); String sTimezone = calEvent.getTimezone(); log.info("source timezone id: " + sTimezone); TimeZone timezone = null; if (sTimezone != null && sTimezone.length() > 0) { timezone = registry.getTimeZone(sTimezone); // Eg Pacific/Auckland } // //VTimeZone tz = timezone.getVTimeZone(); // Iterator it = calendar.getComponents().iterator(); // while (it.hasNext()) { // Object c = it.next(); // if (c instanceof VTimeZone) { // it.remove(); // } // } // it = vevent.getProperties().iterator(); // while (it.hasNext()) { // Object c = it.next(); // if (c instanceof TzId) { // it.remove(); // } // } // if (timezone != null) { // VTimeZone tz = timezone.getVTimeZone(); // calendar.getComponents().add(tz); // vevent.getProperties().add(tz.getTimeZoneId()); // } else { // log.warn("No timezone!"); // } net.fortuna.ical4j.model.DateTime start = CalUtils.toCalDateTime(calEvent.getStartDate(), timezone); net.fortuna.ical4j.model.DateTime finish = CalUtils.toCalDateTime(calEvent.getEndDate(), timezone); vevent.getStartDate().setDate(start); vevent.getStartDate().setTimeZone(timezone); vevent.getEndDate().setDate(finish); vevent.getEndDate().setTimeZone(timezone); String summary = calEvent.getSummary(); if (summary == null || summary.length() == 0) { throw new RuntimeException("no summary"); } vevent.getSummary().setValue(summary); if (vevent.getDescription() != null) { vevent.getDescription().setValue(calEvent.getDescription()); } else { Description d = new Description(calEvent.getDescription()); vevent.getProperties().add(d); } return formatIcal(calendar); } /** * Given an updated calendar object, apply updates to CalEvent * * @param calendar * @param calEvent * @param session */ private void _setCalendar(net.fortuna.ical4j.model.Calendar calendar, CalEvent calEvent, boolean isAccept, Session session) { VEvent ev = event(calendar); calEvent.setStartDate(ev.getStartDate().getDate()); Date endDate = null; if (ev.getEndDate() != null) { endDate = ev.getEndDate().getDate(); } calEvent.setEndDate(endDate); String summary = null; if (ev.getSummary() != null) { summary = ev.getSummary().getValue(); } calEvent.setSummary(summary); String tzId = getTimeZoneId(calendar); calEvent.setTimezone(tzId); calEvent.setModifiedDate(new Date()); String loc = null; if (ev.getLocation() != null) { ev.getLocation().getValue(); } calEvent.setLocation(loc); session.save(calEvent); if (!isAccept) { log.info("not an invitation, so check/create invites"); setRsvps(ev, calEvent, session); } } private VEvent event(net.fortuna.ical4j.model.Calendar cal) { return (VEvent) cal.getComponent("VEVENT"); } public String getTimeZoneId(net.fortuna.ical4j.model.Calendar calendar) { Iterator it = calendar.getComponents().iterator(); while (it.hasNext()) { Object c = it.next(); if (c instanceof VTimeZone) { VTimeZone tz = (VTimeZone) c; net.fortuna.ical4j.model.property.TzId tzId = tz.getTimeZoneId(); if (tzId != null) { return tzId.getValue(); } } } return null; } public String getDefaultColor() { return defaultColor; } public void setDefaultColor(String defaultColor) { this.defaultColor = defaultColor; } /** * Updates teh CalEvent from the ical data. Also updates any child attendee * requests * * @param event * @param data * @param callback * @param session * @return * @throws IOException * @throws ParserException */ private String _update(CalEvent event, String data, UpdatedAttendeeCallback callback, Session session) throws IOException, ParserException { System.out.println("Update Event--"); System.out.println(data); System.out.println("----"); CalendarBuilder builder = new CalendarBuilder(); net.fortuna.ical4j.model.Calendar calendar = builder.build(new ByteArrayInputStream(data.getBytes("UTF-8"))); AttendeeRequest invite = AttendeeRequest.findByName(event.getName(), session); boolean isAccept = invite != null; boolean needsReInvite = hasSchedulingInfoChanged(event, calendar); log.info("needsReInvite? " + needsReInvite); _setCalendar(calendar, event, isAccept, session); List<AttendeeRequest> attendeeRequests = AttendeeRequest.findByOrganisorEvent(event, session); if (!attendeeRequests.isEmpty()) { log.info("update attendee requests"); for (AttendeeRequest ar : attendeeRequests) { CalEvent attendeeEvent = ar.getAttendeeEvent(); if (attendeeEvent != null) { updateAttendeeEvent(ar, needsReInvite, attendeeEvent, event, calendar, callback, session); } } } return formatIcal(calendar); } public String formatIcal(net.fortuna.ical4j.model.Calendar calendar) { CalendarOutputter outputter = new CalendarOutputter(); ByteArrayOutputStream bout = new ByteArrayOutputStream(); try { outputter.output(calendar, bout); } catch (IOException | ValidationException ex) { throw new RuntimeException(ex); } return bout.toString(); } public String getCalendarInvitationsCTag(Profile user) { try { // combine and hash names and mod dates for AR's List<AttendeeRequest> list = getAttendeeRequests(user, true); MessageDigest cout = MessageDigest.getInstance("SHA"); Charset charset = Charset.forName("UTF-8"); for (AttendeeRequest ar : list) { String s = ar.getName() + "-" + ar.getOrganiserEvent().getModifiedDate() + "-" + ar.getParticipationStatus(); cout.update(s.getBytes(charset)); } byte[] arr = cout.digest(); String hash = DigestUtils.shaHex(arr); return hash; } catch (NoSuchAlgorithmException ex) { throw new RuntimeException(ex); } } public List<AttendeeRequest> getAttendeeRequests(Profile user) { return getAttendeeRequests(user, true); } public List<AttendeeRequest> getAttendeeRequests(Profile user, boolean includeAckd) { log.info("getAttendeeRequests: " + user.getName()); List<AttendeeRequest> list = new ArrayList<>(); if (user.getAttendeeRequests() != null) { for (AttendeeRequest ar : user.getAttendeeRequests()) { if (includeAckd || !ar.isAcknowledged() && ar.getAttendeeEvent() == null) { list.add(ar); } } } log.info("getAttendeeRequests: found requests: " + list.size()); return list; } /** * Has any of the scheduling related fields in the event changed relative to * its calendar representation * * @param event * @param calendar * @return */ private boolean hasSchedulingInfoChanged(CalEvent event, net.fortuna.ical4j.model.Calendar calendar) { VEvent ev = event(calendar); Date calStart = ev.getStartDate().getDate(); Date calEnd = ev.getEndDate().getDate(); String calTzId = getTimeZoneId(calendar); String calLoc = null; if (ev.getLocation() != null) { ev.getLocation().getValue(); } return anyChanged(calStart, event.getStartDate(), calEnd, event.getEndDate(), calTzId, event.getTimezone(), calLoc, event.getLocation()); } /** * Given a series of pairs, return true if any of the pairs have not-equal * values * * @param pairs * @return */ private boolean anyChanged(Object... pairs) { for (int i = 0; i < pairs.length; i += 2) { Object v1 = pairs[i]; Object v2 = pairs[i + 1]; if (v1 == null) { if (v2 != null) { return true; } } else { if (!v1.equals(v2)) { return true; } } } return false; } private String findEmail(Attendee att) { String mail = att.getCalAddress().toString(); mail = mail.replace("mailto:", ""); return mail; } public interface UpdatedEventCallback { public void updated(String ical, CalEvent updated) throws IOException; } public interface UpdatedAttendeeCallback { void updated(CalEvent updated) throws IOException; void deleted(CalEvent deleted) throws IOException; } }