aboutsummaryrefslogtreecommitdiff
path: root/lib/classes/calendar/ICalendarExport.php
diff options
context:
space:
mode:
Diffstat (limited to 'lib/classes/calendar/ICalendarExport.php')
-rw-r--r--lib/classes/calendar/ICalendarExport.php628
1 files changed, 628 insertions, 0 deletions
diff --git a/lib/classes/calendar/ICalendarExport.php b/lib/classes/calendar/ICalendarExport.php
new file mode 100644
index 0000000..d8d1af7
--- /dev/null
+++ b/lib/classes/calendar/ICalendarExport.php
@@ -0,0 +1,628 @@
+<?php
+/**
+ * ICalendarExport.php
+ *
+ * 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 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author Peter Thienel <thienel@data-quest.de>
+ * @author Moritz Strohm <strohm@data-quest.de>
+ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category Stud.IP
+ * @since 5.5
+ */
+
+
+class ICalendarExport
+{
+ /**
+ * Line break used in iCalendar
+ */
+ const NEWLINE = "\r\n";
+
+ /**
+ * Default start of the week
+ */
+ const WEEKSTART = 'MO';
+
+ /**
+ * Holds the time (as unix timestamp) used for
+ * the timestamp in every exported iCalendar object.
+ *
+ * @var int $time
+ */
+ private $time = 0;
+
+ public function __construct()
+ {
+ $this->default_filename_suffix = "ics";
+ $this->format = "iCalendar";
+ }
+
+ public function exportCalendarDates(string $range_id, DateTimeInterface $start, DateTimeInterface $end): string
+ {
+ if ($this->time === 0) {
+ $this->time = time();
+ }
+ $dates = CalendarDate::findBySQL(
+ "LEFT JOIN `calendar_date_assignments`
+ ON `calendar_dates`.`id` = `calendar_date_assignments`.`calendar_date_id`
+ WHERE
+ `calendar_date_assignments`.`range_id` = :range_id
+ AND (
+ (`calendar_dates`.`begin` <= :end
+ AND `calendar_dates`.`end` >= :begin)
+ OR (`calendar_dates`.`repetition_type` != 'SINGLE'
+ AND (`calendar_dates`.`repetition_end` >= :begin
+ OR `calendar_dates`.`repetition_end` = 0)
+ AND `calendar_dates`.`begin` < :end))",
+ [
+ ':range_id' => $range_id,
+ ':begin' => $start->getTimestamp(),
+ ':end' => $end->getTimestamp(),
+ ]
+ );
+ $ical = '';
+ foreach ($dates as $date) {
+ $ical .= $this->writeICalEvent($this->prepareCalendarDate($date));
+ }
+ return $ical;
+ }
+
+ public function exportCourseDates(string $user_id, DateTimeInterface $start, DateTimeInterface $end)
+ {
+ if ($this->time === 0) {
+ $this->time = time();
+ }
+ $dates = CalendarCourseDate::getEvents($start, $end, $user_id);
+ $ical = '';
+ foreach ($dates as $date) {
+ $ical .= $this->writeICalEvent($this->prepareCourseDate($date));
+ }
+ return $ical;
+ }
+
+ public function exportCourseExDates(string $user_id, DateTimeInterface $start, DateTimeInterface $end)
+ {
+ if ($this->time === 0) {
+ $this->time = time();
+ }
+ $dates = CalendarCourseExDate::getEvents($start, $end, $user_id);
+ $ical = '';
+ foreach ($dates as $date) {
+ $ical .= $this->writeICalEvent($this->prepareCourseDate($date));
+ }
+ return $ical;
+ }
+
+ /**
+ * @param CalendarDate $date The calendar date to export.
+ * @return array Calendar date data prepared for export.
+ */
+ public function prepareCalendarDate(CalendarDate $date): array
+ {
+ return [
+ 'SUMMARY' => $date->title,
+ 'DESCRIPTION' => $date->description,
+ 'LOCATION' => $date->location,
+ 'CATEGORIES' => $date->getCategoryAsString(),
+ 'LAST-MODIFIED' => $date->chdate,
+ 'CREATED' => $date->mkdate,
+ 'DTSTAMP' => $this->time,
+ 'DTSTART' => $date->begin,
+ 'DTEND' => $date->end,
+ 'EXDATE' => implode(',', $date->exceptions->pluck('date')),
+ 'PRIORITY' => 5,
+ 'RRULE' => [
+ 'type' => $date->repetition_type,
+ 'offset' => $date->offset,
+ 'interval' => $date->interval,
+ 'days' => $date->days,
+ 'count' => $date->number_of_dates,
+ 'expire' => $date->repetition_end,
+ 'month' => $date->month
+ ],
+ 'UID' => $date->unique_id
+ ];
+ }
+
+ /**
+ * @param CourseDate | CourseExDate $date The course date to export.
+ * @return array Course date data prepared for export.
+ */
+ public function prepareCourseDate($date): array
+ {
+ $summary = $date->course->getFullName();
+ $categories = $date->getTypeName();
+ if ($date instanceof CourseExDate) {
+ $summary .= ' ' . _('(fällt aus)');
+ $categories = '';
+ $description = $date->content;
+ } else {
+ $description = implode("\n", $date->topics->pluck('title'));
+ }
+ return [
+ 'SUMMARY' => $summary,
+ 'DESCRIPTION' => $description,
+ 'LOCATION' => $date->getRoomName(),
+ 'CATEGORIES' => $categories,
+ 'LAST-MODIFIED' => $date->chdate,
+ 'CREATED' => $date->mkdate,
+ 'DTSTAMP' => $this->time,
+ 'DTSTART' => $date->date,
+ 'DTEND' => $date->end_time,
+ 'PRIORITY' => '',
+ 'UID' => 'Stud.IP-SEM-' . $date->id . '@' . ($_SERVER['SERVER_NAME'] ?? '')
+ ];
+ }
+
+ /**
+ * Returns an iCalendar header with a rudimentary time zone definition.
+ *
+ * @return string The iCalendar header.
+ */
+ public function writeHeader()
+ {
+ // Default values
+ $header = "BEGIN:VCALENDAR" . self::NEWLINE;
+ $header .= "VERSION:2.0" . self::NEWLINE;
+ if (isset($this->client_identifier)) {
+ $header .= "PRODID:" . $this->client_identifier . self::NEWLINE;
+ } else {
+ $server_name = $_SERVER['SERVER_NAME'] ?? 'unknown';
+
+ $header .= "PRODID:-//Stud.IP@{$server_name}//Stud.IP_iCalendar Library";
+ $header .= " //EN" . self::NEWLINE;
+ }
+ $header .= "METHOD:PUBLISH" . self::NEWLINE;
+
+ // time zone definition CET/CEST
+ $header .= 'CALSCALE:GREGORIAN' . self::NEWLINE
+ . 'BEGIN:VTIMEZONE' . self::NEWLINE
+ . 'TZID:Europe/Berlin' . self::NEWLINE
+ . 'BEGIN:DAYLIGHT' . self::NEWLINE
+ . 'TZOFFSETFROM:+0100' . self::NEWLINE
+ . 'TZOFFSETTO:+0200' . self::NEWLINE
+ . 'TZNAME:CEST' . self::NEWLINE
+ . 'DTSTART:19700329T020000' . self::NEWLINE
+ . 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3' . self::NEWLINE
+ . 'END:DAYLIGHT' . self::NEWLINE
+ . 'BEGIN:STANDARD' . self::NEWLINE
+ . 'TZOFFSETFROM:+0200' . self::NEWLINE
+ . 'TZOFFSETTO:+0100' . self::NEWLINE
+ . 'TZNAME:CET' . self::NEWLINE
+ . 'DTSTART:19701025T030000' . self::NEWLINE
+ . 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10' . self::NEWLINE
+ . 'END:STANDARD' . self::NEWLINE
+ . 'END:VTIMEZONE' .self::NEWLINE;
+
+ return $header;
+ }
+
+ /**
+ * Returns the footer.
+ *
+ * @return string
+ */
+ public function writeFooter()
+ {
+ return "END:VCALENDAR" . self::NEWLINE;
+ }
+
+ /**
+ * Export prepared calendar data as iCalendar.
+ *
+ * @param array $properties The event to export.
+ * @return string iCalendar formatted data
+ */
+ public function writeICalEvent(array $properties): string
+ {
+ $result = "BEGIN:VEVENT" . self::NEWLINE;
+
+ foreach ($properties as $name => $value) {
+ $params = [];
+ $params_str = '';
+ if ($value === '' || is_null($value)) {
+ continue;
+ }
+ switch ($name) {
+ // not supported event properties
+ case 'SEMNAME':
+ continue 2;
+
+ // Text fields
+ case 'SUMMARY':
+ $value = $this->quoteText($value);
+ break;
+ case 'DESCRIPTION':
+ $value = $this->quoteText($value);
+ break;
+ case 'LOCATION':
+ $value = $this->quoteText($value);
+ break;
+ case 'CATEGORIES':
+ $value = $this->quoteText($value);
+ break;
+
+ // Date fields
+ case 'LAST-MODIFIED':
+ case 'CREATED':
+ case 'COMPLETED':
+ $value = $this->_exportDateTime($value, true);
+ break;
+
+ case 'DTSTAMP':
+ $value = $this->_exportDateTime(time(), true);
+ break;
+
+ case 'DTSTART':
+ $exdate_time = $value;
+ case 'DTEND':
+ case 'DUE':
+ case 'RECURRENCE-ID':
+ if (array_key_exists('VALUE', $params)) {
+ if ($params['VALUE'] === 'DATE') {
+ $value = $this->_exportDate($value);
+ } else {
+ $value = $this->_exportDateTime($value);
+ $params_str = ';TZID=Europe/Berlin';
+ }
+ } else {
+ $value = $this->_exportDateTime($value);
+ $params_str = ';TZID=Europe/Berlin';
+ }
+ break;
+
+ case 'EXDATE':
+ if (array_key_exists('VALUE', $params) && $params['VALUE'] === 'DATE') {
+ $value = $this->exportExDate($value);
+ } else {
+ $value = $this->exportExDateTime($value, $exdate_time);
+ $params_str = ';TZID=Europe/Berlin';
+ }
+ break;
+
+ // Integer fields
+ case 'PERCENT-COMPLETE':
+ case 'REPEAT':
+ case 'SEQUENCE':
+ $value = "$value";
+ break;
+
+ case 'PRIORITY':
+ switch ($value) {
+ case 1:
+ $value = '1';
+ break;
+ case 2:
+ $value = '5';
+ break;
+ case 3:
+ $value = '9';
+ break;
+ default:
+ $value = '0';
+ }
+ break;
+
+ // Geo fields
+ case 'GEO':
+ $value = $value['latitude'] . ',' . $value['longitude'];
+ break;
+
+ // Recursion fields
+ case 'EXRULE':
+ case 'RRULE':
+ if ($value['type'] !== 'SINGLE' && $value['type'] !== '') {
+ $value = $this->_exportRecurrence($value);
+ }
+ break;
+
+ case "UID":
+ $value = "$value";
+ }
+ if ($name && !is_array($value)) {
+ $attr_string = $name . $params_str . ':' . $value;
+ $result .= $this->foldLine($attr_string) . self::NEWLINE;
+ }
+ }
+ if (isset($properties['GROUP_EVENT'])) {
+ $result .= $this->exportGroupEventProperties($properties['GROUP_EVENT']);
+ }
+ $result .= "END:VEVENT" . self::NEWLINE;
+
+ return $result;
+ }
+
+ /**
+ * Quotes some characters accordingly to iCalendar format.
+ *
+ * @param string $text The text to quote.
+ * @return string The quoted text.
+ */
+ public function quoteText(string $text): string
+ {
+ $match = ['\\', '\n', ';', ','];
+ $replace = ['\\\\', '\\n', '\;', '\,'];
+ return str_replace($match, $replace, $text);
+ }
+
+ /**
+ * Export a DateTime field
+ *
+ * @param int $value Unix timestamp
+ * @return String Date and time (UTC) iCalendar formatted
+ */
+ public function _exportDateTime($value, $utc = false)
+ {
+ $date_time = new DateTime();
+ $date_time->setTimestamp(intval($value));
+ //transform local time to UTC
+ if ($utc) {
+ $tz_utc = new DateTimeZone('UTC');
+ $date_time->setTimezone($tz_utc);
+ return $date_time->format('Ymd\THis\Z');
+ }
+ return $date_time->format('Ymd\THis');
+ }
+
+ /**
+ * Export a Time field
+ *
+ * @param int $value Unix timestamp
+ * @return String Time (UTC) iCalendar formatted
+ */
+ public function _exportTime($value, $utc = false)
+ {
+ $time = date("His", $value);
+ if ($utc) {
+ $time .= 'Z';
+ }
+
+ return $time;
+ }
+
+ /**
+ * Export a Date field
+ */
+ public function _exportDate($value)
+ {
+ return date("Ymd", $value);
+ }
+
+ /**
+ * Export a recurrence rule
+ */
+ public function _exportRecurrence($value)
+ {
+ $rrule = [];
+ // the last day of week in a MONTHLY or YEARLY recurrence in the
+ // Stud.IP calendar is 5, in iCalendar it is -1
+ if ($value['offset'] == '5') {
+ $value['offset'] = '-1';
+ }
+
+ if ($value['count'] > 1) {
+ unset($value['expire']);
+ }
+
+ foreach ($value as $r_param => $r_value) {
+ if ($r_value) {
+ switch ($r_param) {
+ case 'type':
+ $rrule[] = 'FREQ=' . $r_value;
+ break;
+ case 'expire':
+ if ($r_value)
+ $rrule[] = 'UNTIL=' . $this->_exportDateTime($r_value, true);
+ break;
+ case 'interval':
+ $rrule[] = 'INTERVAL=' . $r_value;
+ break;
+ case 'days':
+ switch ($value['type']) {
+ case 'WEEKLY':
+ $rrule[] = 'BYDAY=' . $this->_exportWdays($r_value);
+ break;
+ // Some CUAs (e.g. Outlook) don't understand the nWDAY syntax
+ // (where n is the nth ocurrence of the day in a given period of
+ // time and WDAY is the day of week) the RRULE uses the BYSETPOS
+ // rule.
+ case 'MONTHLY':
+ case 'YEARLY':
+ $rrule[] = 'BYDAY=' . $value['offset'] . $this->_exportWdays($r_value);
+ $rrule[] = 'BYDAY=' . $this->_exportWdays($r_value);
+ if ($value['offset']) {
+ $rrule[] = 'BYSETPOS=' . $value['offset'];
+ }
+ break;
+ }
+ break;
+ case 'day':
+ $rrule[] = 'BYMONTHDAY=' . $r_value;
+ break;
+ case 'month':
+ $rrule[] = 'BYMONTH=' . $r_value;
+ break;
+ case 'count':
+ if ($r_value > 1) {
+ $rrule[] = 'COUNT=' . $r_value;
+ }
+ break;
+ }
+ }
+ }
+
+ if ($value['type'] === 'WEEKLY' && self::WEEKSTART != 'MO') {
+ $rrule[] = 'WKST=' . self::WEEKSTART;
+ }
+
+ return implode(';', $rrule);
+ }
+
+ /**
+ * Return the days from CalendarDate::days as attribute of a event recurrence.
+ *
+ * @param string $value
+ * @return string
+ */
+ public function _exportWdays(string $value): string
+ {
+ $wdays_map = ['1' => 'MO', '2' => 'TU', '3' => 'WE', '4' => 'TH', '5' => 'FR',
+ '6' => 'SA', '7' => 'SU'];
+ $wdays = [];
+ preg_match_all('/(\d)/', $value, $matches);
+ foreach ($matches[1] as $match) {
+ $wdays[] = $wdays_map[$match];
+ }
+ return implode(',', $wdays);
+ }
+
+
+ /**
+ * Formats dates of exception.
+ *
+ * @param string $value Date values (Y-m-d) as csv list.
+ * @return string The formatted Exceptions.
+ */
+ public function exportExDate(string $value): string
+ {
+ $ex_dates = [];
+ $dates = explode(',', $value);
+ foreach ($dates as $date) {
+ $ex_datetime = $date . ' 12:00:00';
+ $ex_date = DateTime::createFromFormat('Y-m-d H:i:s', $ex_datetime);
+ $ex_dates[] = $this->_exportDate($ex_date->getTimestamp());
+ }
+
+ return implode(',', $ex_dates);
+ }
+
+ /**
+ * Formats date times of exception.
+ *
+ * @param string $value Date values (Y-m-d) as csv list.
+ * @param int $begin Start date of event as unix timestamp.
+ * @return string The formatted Exceptions.
+ */
+ public function exportExDateTime(string $value, int $begin): string
+ {
+ $ex_dates = [];
+ $dates = explode(',', $value);
+ foreach ($dates as $date) {
+ $ex_datetime = $date . date(' H:i:s', $begin);
+ $date_time = DateTime::createFromFormat('Y-m-d H:i:s', $ex_datetime);
+ $ex_dates[] = $date_time->format('Ymd\THis');
+ }
+ return implode(',', $ex_dates);
+ }
+
+ /**
+ * Returns iCalendar group event properties if the date has mor than one attendee.
+ *
+ * @param CalendarDate $date The date object to extract the group data from.
+ * @return string The formatted group event properties.
+ */
+ private function exportGroupEventProperties(CalendarDate $date): string
+ {
+ if (!count($date->calendars)) {
+ return '';
+ }
+ $organizer = $date->author;
+ if ($organizer) {
+ $properties = $this->foldLine('ORGANIZER;CN="'
+ . $organizer->getFullName()
+ . '":mailto:' . $organizer->Email)
+ . self::NEWLINE;
+ } else {
+ $properties = $this->foldLine('ORGANIZER;CN="'
+ . _('unbekannt')
+ . '":mailto:' . $GLOBALS['user']->email)
+ . self::NEWLINE;
+ }
+ foreach ($date->calendars as $calendar) {
+ if ($date->author_id === $calendar->range_id) {
+ if ($calendar->user) {
+ $properties .= $this->foldLine('ATTENDEE;'
+ . 'ROLE=REQ-PARTICIPANT;'
+ . 'CN="' . $calendar->user->getFullName()
+ . '":mailto:' . $calendar->user->Email)
+ . self::NEWLINE;
+ } else {
+ $properties = '';
+ }
+ } else {
+ if ($calendar->user) {
+ switch ($calendar->participation) {
+ case 'ACCEPTED' :
+ $attendee = 'ATTENDEE;ROLE=REQ-PARTICIPANT'
+ . ';PARTSTAT=ACCEPTED';
+ break;
+ case 'ACKNOWLEDGED' :
+ $attendee = 'ATTENDEE;ROLE=NON-PARTICIPANT'
+ . ';PARTSTAT=ACCEPTED'
+ . ';DELEGATED-TO="mailto:'
+ . $this->getFacultyEmail($organizer->id)
+ . '"';
+ break;
+ case 'DECLINED' :
+ $attendee = 'ATTENDEE;ROLE=REQ-PARTICIPANT'
+ . ';PARTSTAT=DECLINED';
+ break;
+ default :
+ $attendee = 'ATTENDEE;ROLE=REQ-PARTICIPANT';
+ $attendee .= ';PARTSTAT=TENTATIVE';
+ $attendee .= ';RSVP=TRUE';
+
+ }
+ $attendee .= ';CN="' . $calendar->user->getFullName()
+ . '":mailto:' . $calendar->user->Email;
+ $properties .= $this->foldLine($attendee) . self::NEWLINE;
+ }
+ }
+ }
+ return $properties;
+ }
+
+ /**
+ * @param string $user_id
+ * @return string
+ */
+ private function getFacultyEmail(string $user_id): string
+ {
+ return DBManager::get()->fetchColumn('
+ SELECT `email`
+ FROM `Institute`
+ LEFT JOIN `user_inst` USING(`institut_id`)
+ WHERE `Institute`.`Institut_id` = `fakultaets_id`
+ AND `user_id` = ?', [$user_id]);
+ }
+
+ /**
+ * Returns the folded version of a text line.
+ *
+ * @param string $line
+ * @return string
+ */
+ private function foldLine(string $line): string
+ {
+ $line = preg_replace('/(\r\n|\n|\r)/', '\n', $line);
+ if (mb_strlen($line) > 75) {
+ $foldedline = '';
+ while ($line !== '') {
+ $maxLine = mb_substr($line, 0, 75);
+ $cutPoint = max(60, max(mb_strrpos($maxLine, ';'), mb_strrpos($maxLine, ':')) + 1);
+
+ $foldedline .= ( empty($foldedline)) ?
+ mb_substr($line, 0, $cutPoint) :
+ self::NEWLINE . ' ' . mb_substr($line, 0, $cutPoint);
+
+ $line = (mb_strlen($line) <= $cutPoint) ? '' : mb_substr($line, $cutPoint);
+ }
+ return $foldedline;
+ }
+ return $line;
+ }
+}