blob: f41e4a12cf913a04cbb88da57709093ea08184c2 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
package org.apache.openmeetings.service.calendar.caldav;
import static java.util.UUID.randomUUID;
import static org.apache.openmeetings.db.util.TimezoneUtil.getTimeZone;
import static org.apache.openmeetings.util.CalendarHelper.getZoneDateTime;
import static org.apache.openmeetings.util.mail.IcalHandler.TZ_REGISTRY;
import static org.apache.openmeetings.util.mail.MailUtil.MAILTO;
import static org.apache.openmeetings.util.mail.MailUtil.SCHEME_MAILTO;
import java.net.URI;
import java.text.ParsePosition;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.temporal.Temporal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.openmeetings.db.dao.user.UserDao;
import org.apache.openmeetings.db.entity.calendar.Appointment;
import org.apache.openmeetings.db.entity.calendar.MeetingMember;
import org.apache.openmeetings.db.entity.calendar.OmCalendar;
import org.apache.openmeetings.db.entity.room.Room;
import org.apache.openmeetings.db.entity.user.User;
import org.apache.wicket.protocol.http.WebSession;
import org.apache.wicket.util.string.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.ParameterList;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.component.CalendarComponent;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.parameter.Cn;
import net.fortuna.ical4j.model.parameter.Role;
import net.fortuna.ical4j.model.property.Attendee;
import net.fortuna.ical4j.model.property.CalScale;
import net.fortuna.ical4j.model.property.DateProperty;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.Organizer;
import net.fortuna.ical4j.model.property.ProdId;
import net.fortuna.ical4j.model.property.Sequence;
import net.fortuna.ical4j.model.property.Transp;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.transform.recurrence.Frequency;
/**
* Class which provides iCalendar Utilities.
* This class's functions could be made static, as they are not instantiated anyway.
*/
@org.springframework.stereotype.Component
public class IcalUtils {
private static final Logger log = LoggerFactory.getLogger(IcalUtils.class);
private static final List<String> acceptedFormats = List.of("yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmmss'Z'", "yyyyMMdd");
public static final String PROD_ID = "-//Events Calendar//Apache Openmeetings//EN";
@Autowired
private UserDao userDao;
/**
* Parses the Calendar from the CalDAV server, to a new Appointment.
*
* @param calendar iCalendar Representation.
* @param href Location of the Calendar on the server
* @param etag ETag of the calendar.
* @param omCalendar The Parent OmCalendar, to which the Appointment belongs.
* @return Appointment after parsing.
*/
public Appointment parseCalendartoAppointment(Calendar calendar, String href, String etag, OmCalendar omCalendar) {
//Note: By RFC 4791 only one event can be stored in one href.
Appointment a = new Appointment();
a.setId(null);
a.setDeleted(false);
a.setHref(href);
a.setCalendar(omCalendar);
a.setOwner(omCalendar.getOwner());
a.setRoom(createDefaultRoom());
a.setReminder(Appointment.Reminder.NONE);
return this.parseCalendartoAppointment(a, calendar, etag);
}
/**
* Parses a Calendar with multiple VEvents into Appointments
*
* @param calendar Calendar to Parse
* @param ownerId Owner of the Appointments
* @return <code>List</code> of Appointments
*/
public List<Appointment> parseCalendartoAppointments(Calendar calendar, Long ownerId) {
List<Appointment> appointments = new ArrayList<>();
List<CalendarComponent> events = calendar.getComponents(Component.VEVENT);
User owner = userDao.get(ownerId);
for (CalendarComponent event : events) {
Appointment a = new Appointment();
a.setOwner(owner);
a.setDeleted(false);
a.setRoom(createDefaultRoom());
a.setReminder(Appointment.Reminder.NONE);
appointments.add(addVEventPropertiestoAppointment(a, event));
}
return appointments;
}
/**
* Updating Appointments which already exist, by parsing the Calendar. And updating etag.
* Doesn't work with complex Recurrences.
* Note: Hasn't been tested to acknowledge DST, timezones should acknowledge this.
*
* @param a Appointment to be updated.
* @param calendar iCalendar Representation.
* @param etag The ETag of the calendar.
* @return Updated Appointment.
*/
public Appointment parseCalendartoAppointment(Appointment a, Calendar calendar, String etag) {
if (calendar == null) {
return a;
}
calendar.getComponent(Component.VEVENT).ifPresent(event -> {
a.setEtag(etag);
addVEventPropertiestoAppointment(a, event);
});
return a;
}
private Date getDate(CalendarComponent event, String prop) {
return getDate(event.getProperty(prop).orElse(null));
}
@SuppressWarnings("unchecked")
private Date getDate(Property prop) {
return prop == null ? null : Date.from(Instant.from(((DateProperty<? extends Temporal>)prop).getDate()));
}
/**
* Add properties from the Given VEvent Component to the Appointment
*
* @param a Appointment to which the properties are to be added
* @param event VEvent to parse properties from.
* @return Updated Appointment
*/
private Appointment addVEventPropertiestoAppointment(Appointment a, CalendarComponent event) {
event.getProperty(Property.UID).ifPresent(uid -> a.setIcalId(uid.getValue()));
Date d = getDate(event, Property.DTSTART);
a.setStart(getDate(event, Property.DTSTART));
event.getProperty(Property.DTEND).ifPresentOrElse(dtend -> a.setEnd(getDate(dtend))
, () -> a.setEnd(addTimetoDate(d, java.util.Calendar.HOUR_OF_DAY, 1)));
event.getProperty(Property.DTSTAMP).ifPresent(dtstamp -> a.setInserted(getDate(dtstamp)));
event.getProperty(Property.LAST_MODIFIED).ifPresent(lastmod -> a.setUpdated(getDate(lastmod)));
event.getProperty(Property.DESCRIPTION).ifPresent(description -> a.setDescription(description.getValue()));
event.getProperty(Property.SUMMARY).ifPresent(summary -> a.setTitle(summary.getValue()));
event.getProperty(Property.LOCATION).ifPresent(location -> a.setLocation(location.getValue()));
event.getProperty(Property.RRULE).ifPresent(recur -> {
recur.getParameter("FREQ").ifPresent(freq -> {
if (freq.getValue().equals(Frequency.DAILY.name())) {
a.setIsDaily(true);
} else if (freq.getValue().equals(Frequency.WEEKLY.name())) {
a.setIsWeekly(true);
} else if (freq.getValue().equals(Frequency.MONTHLY.name())) {
a.setIsMonthly(true);
} else if (freq.getValue().equals(Frequency.YEARLY.name())) {
a.setIsYearly(true);
}
});
});
Set<MeetingMember> attList = a.getMeetingMembers() == null ? new HashSet<>()
: new HashSet<>(a.getMeetingMembers());
AtomicReference<String> organizerEmail = new AtomicReference<>();
//Note this value can be repeated in attendees as well.
event.getProperty(Property.ORGANIZER).ifPresent(organizer -> {
URI uri = URI.create(organizer.getValue());
//If the value of the organizer is an email
if (SCHEME_MAILTO.equals(uri.getScheme())) {
String email = uri.getSchemeSpecificPart();
organizerEmail.set(email);
if (!email.equals(a.getOwner().getAddress().getEmail())) {
//Contact or exist and owner
User org = userDao.getByEmail(email);
if (org == null) {
org = userDao.getContact(email, a.getOwner());
attList.add(createMeetingMember(a, org));
} else if (!org.getId().equals(a.getOwner().getId())) {
attList.add(createMeetingMember(a, org));
}
}
}
});
event.getProperties(Property.ATTENDEE).forEach(attendee -> {
URI uri = URI.create(attendee.getValue());
if (SCHEME_MAILTO.equals(uri.getScheme())) {
String email = uri.getSchemeSpecificPart();
Optional<Role> role = attendee.getParameter(Role.CHAIR.getName());
if (role.isPresent() && role.get().getValue().equals(Role.CHAIR.getValue()) && email.equals(organizerEmail.get())) {
return;
}
User u = userDao.getByEmail(email);
if (u == null) {
u = userDao.getContact(email, a.getOwner());
}
attList.add(createMeetingMember(a, u));
}
});
a.setMeetingMembers(attList.isEmpty() ? null : new ArrayList<>(attList));
return a;
}
private static MeetingMember createMeetingMember(Appointment a, User u) {
MeetingMember mm = new MeetingMember();
mm.setUser(u);
mm.setDeleted(false);
mm.setInserted(a.getInserted());
mm.setUpdated(a.getUpdated());
mm.setAppointment(a);
return mm;
}
private static Room createDefaultRoom() {
Room r = new Room();
r.setAppointment(true);
if (r.getType() == null) {
r.setType(Room.Type.CONFERENCE);
}
return r;
}
/**
* Parses the VTimezone Component of the given Calendar. If no, VTimezone component is found the
* User Timezone is used
*
* @param calendar Calendar to parse
* @param owner Owner of the Calendar
* @return Parsed TimeZone
*/
public TimeZone parseTimeZone(Calendar calendar, User owner) {
return Optional.ofNullable(calendar)
.map(cal -> cal.getComponent(Component.VTIMEZONE))
.filter(Optional::isPresent)
.map(Optional::get)
.map(timezone -> timezone.getProperty(Property.TZID))
.filter(Optional::isPresent)
.map(Optional::get)
.map(tzid -> getTimeZone(tzid.getValue()))
.orElse(getTimeZone(owner));
}
/**
* Convenience function to parse date from {@link net.fortuna.ical4j.model.Property} to
* {@link Date}
*
* @param dt DATE-TIME Property from which we parse.
* @param timeZone Timezone of the Date.
* @return {@link java.util.Date} representation of the iCalendar value.
*/
public Date parseDate(Property dt, TimeZone timeZone) {
if (dt == null || Strings.isEmpty(dt.getValue())) {
return null;
}
return dt.getParameter(Parameter.TZID)
.map(tzid -> parseDate(dt.getValue(), getTimeZone(tzid.getValue())))
.orElse(parseDate(dt.getValue(), timeZone));
}
/**
* Adapted from DateUtils to support Timezones, and parse ical dates into {@link java.util.Date}.
* Note: Replace FastDateFormat to java.time, when shifting to Java 8 or higher.
*
* @param str Date representation in String.
* @param patterns Patterns to parse the date against
* @param inTimeZone Timezone of the Date.
* @return <code>java.util.Date</code> representation of string or
* <code>null</code> if the Date could not be parsed.
*/
public Date parseDate(String str, TimeZone inTimeZone) {
FastDateFormat parser;
Locale locale = WebSession.get().getLocale();
TimeZone timeZone = str.endsWith("Z") ? TimeZone.getTimeZone("UTC") : inTimeZone;
ParsePosition pos = new ParsePosition(0);
for (String pattern : acceptedFormats) {
parser = FastDateFormat.getInstance(pattern, timeZone, locale);
pos.setIndex(0);
Date date = parser.parse(str, pos);
if (date != null && pos.getIndex() == str.length()) {
return date;
}
}
log.error("Unable to parse the date: {} at {}", str, -1);
return null;
}
/**
* Adds a specified amount of time to a Date.
*
* @param date Date to which time is added
* @param field Date Field to which the Amount is added
* @param amount Amount to be Added
* @return New Date
*/
public Date addTimetoDate(Date date, int field, int amount) { //FIXME TODO
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTime(date);
c.add(field, amount);
return c.getTime();
}
private net.fortuna.ical4j.model.TimeZone getTimazone(String tzid) {
net.fortuna.ical4j.model.TimeZone timeZone = TZ_REGISTRY.getTimeZone(tzid);
if (timeZone == null) {
throw new NoSuchElementException("Unable to get time zone by id provided: " + tzid);
}
return timeZone;
}
private Calendar getCalendar(net.fortuna.ical4j.model.TimeZone timeZone, List<CalendarComponent> events) {
List<CalendarComponent> comps = new ArrayList<>(events);
comps.add(0, timeZone.getVTimeZone());
return new Calendar(
new PropertyList(List.of(new ProdId(PROD_ID), Version.VERSION_2_0, CalScale.GREGORIAN))
, new ComponentList<>(comps));
}
private VEvent parseAppointment(Appointment appointment, net.fortuna.ical4j.model.TimeZone timeZone) {
ZonedDateTime start = getZoneDateTime(appointment.getStart(), timeZone.getID());
ZonedDateTime end = getZoneDateTime(appointment.getEnd(), timeZone.getID());
VEvent meeting = new VEvent(start, end, appointment.getTitle());
List<Property> mProperties = new ArrayList<>(meeting.getProperties());
if (appointment.getLocation() != null) {
mProperties.add(new Location(appointment.getLocation()));
}
mProperties.add(new Description(appointment.getDescription()));
mProperties.add(new Sequence(0));
mProperties.add(Transp.OPAQUE);
String uid = appointment.getIcalId();
Uid ui;
if (uid == null || uid.length() < 1) {
UUID uuid = randomUUID();
appointment.setIcalId(uuid.toString());
ui = new Uid(uuid.toString());
} else {
ui = new Uid(uid);
}
mProperties.add(ui);
if (appointment.getMeetingMembers() != null) {
for (MeetingMember meetingMember : appointment.getMeetingMembers()) {
mProperties.add(new Attendee(
new ParameterList(List.of(Role.REQ_PARTICIPANT, new Cn(meetingMember.getUser().getLogin())))
, URI.create(MAILTO + meetingMember.getUser().getAddress().getEmail())));
}
}
URI orgUri = URI.create(MAILTO + appointment.getOwner().getAddress().getEmail());
Cn orgCn = new Cn(appointment.getOwner().getLogin());
mProperties.add(new Attendee(new ParameterList(List.of(Role.CHAIR, orgCn)), orgUri));
mProperties.add(new Organizer(new ParameterList(List.of(orgCn)), orgUri));
meeting.setPropertyList(new PropertyList(mProperties));
return meeting;
}
/**
* Methods to parse Appointment to iCalendar according RFC 2445
*
* @param appointment to be converted to iCalendar
* @return iCalendar representation of the Appointment
*/
public Calendar parseAppointmenttoCalendar(Appointment appointment) {
net.fortuna.ical4j.model.TimeZone timeZone = getTimazone(parseTimeZone(null, appointment.getOwner()).getID());
return getCalendar(timeZone, List.of(parseAppointment(appointment, timeZone)));
}
/**
* Parses a List of Appointments into a VCALENDAR component.
*
* @param appointments List of Appointments for the Calendar
* @param ownerId Owner of the Appointments
* @return VCALENDAR representation of the Appointments
*/
public Calendar parseAppointmentstoCalendar(List<Appointment> appointments, Long ownerId) {
net.fortuna.ical4j.model.TimeZone timeZone = getTimazone(parseTimeZone(null, userDao.get(ownerId)).getID());
return getCalendar(timeZone, appointments.stream()
.map(appointment -> parseAppointment(appointment, timeZone))
.collect(Collectors.toList()));
}
}