diff options
Diffstat (limited to 'lib/classes/calendar/ICalendarImport.php')
| -rw-r--r-- | lib/classes/calendar/ICalendarImport.php | 678 |
1 files changed, 678 insertions, 0 deletions
diff --git a/lib/classes/calendar/ICalendarImport.php b/lib/classes/calendar/ICalendarImport.php new file mode 100644 index 0000000..e78696d --- /dev/null +++ b/lib/classes/calendar/ICalendarImport.php @@ -0,0 +1,678 @@ +<?php +class ICalendarImport +{ + private $range_id; + + private $count = 0; + + private $dates = []; + + private $import_time; + + private $convert_to_private = false; + + public function __construct($range_id) + { + $this->range_id = $range_id; + $this->import_time = time(); + } + + public function import($ical_data) + { + $this->parse($ical_data); + } + + public function countEvents($ical_data) + { + $matches = []; + if (is_null($this->count)) { + // Unfold any folded lines + $data = preg_replace('/\x0D?\x0A[\x20\x09]/', '', $ical_data); + preg_match_all('/(BEGIN:VEVENT(\r\n|\r|\n)[\W\w]*?END:VEVENT\r?\n?)/', $ical_data, $matches); + $this->count = sizeof($matches[1]); + } + + return $this->count; + } + + public function getCountEvents() : int + { + return (int) $this->count; + } + + public function convertPublicToPrivate(bool $to_private = true) : void + { + $this->convert_to_private = $to_private; + } + + /** + * Parse a string containing vCalendar data. + * + * @access private + * @param string $data The data to parse + */ + public function parse(string $data) + { + // match categories + $studip_categories = []; + $i = 1; + foreach ($GLOBALS['PERS_TERMIN_KAT'] as $cat) { + $studip_categories[mb_strtolower($cat['name'])] = $i++; + } + + // Unfold any folded lines + // the CR is optional for files imported from Korganizer (non-standard) + $data = $this->unfoldLine($data); + + if (!preg_match('/BEGIN:VCALENDAR(\r\n|\r|\n)([\W\w]*)END:VCALENDAR\r?\n?/', $data, $matches)) { + throw new UnexpectedValueException(); + } + + // client identifier + if (!$this->parseClientIdentifier($matches[2])) { + throw new UnexpectedValueException(); + } + + // All sub components + if (!preg_match_all('/BEGIN:VEVENT(\r\n|\r|\n)([\w\W]*?)END:VEVENT(\r\n|\r|\n)/', $matches[2], $v_events)) { + // _("Die importierte Datei enthält keine Termine.") + throw new UnexpectedValueException(); + } + + if ($this->count) { + $this->count = 0; + } + foreach ($v_events[2] as $v_event) { + + if (preg_match_all('/(.*):(.*)(\r|\n)+/', $v_event, $matches)) { + $properties = []; + $check = []; + foreach ($matches[0] as $property) { + preg_match('/([^;^:]*)((;[^:]*)?):(.*)/', $property, $parts); + $tag = $parts[1]; + $value = $parts[4]; + $params = []; + + // skip seminar events + if ((!$this->import_sem) && $tag == 'UID') { + if (mb_strpos($value, 'Stud.IP-SEM') === 0) { + continue 2; + } + } + + if (!empty($parts[2])) { + preg_match_all('/;(([^;=]*)(=([^;]*))?)/', $parts[2], $param_parts); + foreach ($param_parts[2] as $key => $param_name) + $params[mb_strtoupper($param_name)] = mb_strtoupper($param_parts[4][$key]); + + if ($params['ENCODING']) { + switch ($params['ENCODING']) { + case 'QUOTED-PRINTABLE': + $value = $this->qp_decode($value); + break; + + case 'BASE64': + $value = base64_decode($value); + break; + } + } + } + + switch ($tag) { + // text fields + case 'DESCRIPTION': + case 'SUMMARY': + case 'LOCATION': + $value = preg_replace('/\\\\,/', ',', $value); + $value = preg_replace('/\\\\n/', "\n", $value); + $properties[$tag] = trim($value); + break; + + case 'CATEGORIES': + $categories = []; + $properties['STUDIP_CATEGORY'] = null; + foreach (explode(',', $value) as $category) { + if (!$studip_categories[mb_strtolower($category)]) { + $categories[] = $category; + } else if (!$properties['STUDIP_CATEGORY']) { + $properties['STUDIP_CATEGORY'] + = $studip_categories[mb_strtolower($category)]; + } + } + $properties[$tag] = implode(',', $categories); + break; + + // Date fields + case 'DCREATED': // vCalendar property name for "CREATED" + case 'DTSTAMP': + case 'COMPLETED': + case 'CREATED': + case 'LAST-MODIFIED': + $properties[$tag] = $this->parseDateTime($value); + break; + + case 'DTSTART': + case 'DTEND': + // checking for day events + if ($params['VALUE'] == 'DATE') + $check['DAY_EVENT'] = true; + case 'DUE': + case 'RECURRENCE-ID': + $properties[$tag] = $this->parseDateTime($value); + break; + + case 'RDATE': + if (array_key_exists('VALUE', $params)) { + if ($params['VALUE'] == 'PERIOD') { + $properties[$tag] = $this->parsePeriod($value); + } else { + $properties[$tag] = $this->parseDateTime($value); + } + } else { + $properties[$tag] = $this->parseDateTime($value); + } + break; + + case 'TRIGGER': + if (array_key_exists('VALUE', $params)) { + if ($params['VALUE'] == 'DATE-TIME') { + $properties[$tag] = $this->parseDateTime($value); + } else { + $properties[$tag] = $this->parseDuration($value); + } + } else { + $properties[$tag] = $this->parseDuration($value); + } + break; + + case 'EXDATE': + $properties[$tag] = []; + // comma seperated dates + $values = []; + $dates = []; + preg_match_all('/,([^,]*)/', ',' . $value, $values); + foreach ($values[1] as $value) { + if (array_key_exists('VALUE', $params)) { + if ($params['VALUE'] == 'DATE-TIME') { + $dates[] = $this->parseDateTime($value); + } else if ($params['VALUE'] == 'DATE') { + $dates[] = $this->parseDate($value); + } + } else { + $dates[] = $this->parseDateTime($value); + } + } + // some iCalendar exports (e.g. KOrganizer) use an EXDATE-entry for every + // exception, so we have to merge them + array_merge($properties[$tag], $dates); + break; + + // Duration fields + case 'DURATION': + $attibutes[$tag] = $this->parseDuration($value); + break; + + // Period of time fields + case 'FREEBUSY': + $values = []; + $periods = []; + preg_match_all('/,([^,]*)/', ',' . $value, $values); + foreach ($values[1] as $value) { + $periods[] = $this->parsePeriod($value); + } + + $properties[$tag] = $periods; + break; + + // UTC offset fields + case 'TZOFFSETFROM': + case 'TZOFFSETTO': + $properties[$tag] = $this->parseUtcOffset($value); + break; + + case 'PRIORITY': + $properties[$tag] = $this->parsePriority($value); + break; + + case 'CLASS': + switch (trim($value)) { + case 'PUBLIC': + $properties[$tag] = 'PUBLIC'; + break; + case 'CONFIDENTIAL': + $properties[$tag] = 'CONFIDENTIAL'; + break; + default: + $properties[$tag] = 'PRIVATE'; + } + break; + + // Integer fields + case 'PERCENT-COMPLETE': + case 'REPEAT': + case 'SEQUENCE': + $properties[$tag] = intval($value); + break; + + // Geo fields + case 'GEO': + $floats = explode(';', $value); + $value['latitude'] = floatval($floats[0]); + $value['longitude'] = floatval($floats[1]); + $properties[$tag] = $value; + break; + + // Recursion fields + case 'EXRULE': + case 'RRULE': + $properties[$tag] = $this->parseRecurrence($value); + break; + + default: + // string fields + $properties[$tag] = trim($value); + break; + } + } + + if (!$properties['RRULE']['rtype']) { + $properties['RRULE'] = ['rtype' => 'SINGLE']; + } + + if (!$properties['LAST-MODIFIED']) { + $properties['LAST-MODIFIED'] = $properties['DTSTAMP'] ?: $properties['CREATED'] ?? time(); + } + + if (!$properties['DTSTART'] || ($properties['EXDATE'] && !$properties['RRULE'])) { + // _("Die Datei ist keine gültige iCalendar-Datei!") + throw new UnexpectedValueException(); + } + + if (!$properties['DTEND']) { + $properties['DTEND'] = $properties['DTSTART']; + } + + // day events starts at 00:00:00 and ends at 23:59:59 + if ($check['DAY_EVENT']) + $properties['DTEND']--; + + // default: all imported events are set to private + if (!$properties['CLASS'] + || ($this->convert_to_private && $properties['CLASS'] == 'PUBLIC')) { + $properties['CLASS'] = 'PRIVATE'; + } + + /* + if (isset($studip_categories[$properties['CATEGORIES']])) { + $properties['STUDIP_CATEGORY'] = $studip_categories[$properties['CATEGORIES']]; + $properties['CATEGORIES'] = ''; + } + * + */ + + $this->createDateFromProperties($properties); + } else { + // _("Die Datei ist keine gültige iCalendar-Datei!") + throw new InvalidValuesException(); + } + $this->count++; + } + + return true; + } + + private function createDateFromProperties($properties) + { + $date = CalendarDate::findOneBySQL( + 'LEFT JOIN `calendar_date_assignments` + ON `calendar_dates`.`id` = `calendar_date_assignments`.`calendar_date_id` + WHERE `calendar_dates`.`unique_id` = :uid + AND `calendar_date_assignments`.`range_id` = :range_id', + [ + ':uid' => $properties['UID'], + ':range_id' => $this->range_id + ] + ); + + if (!$date) { + $date = new CalendarDate(); + $date->id = $date->getNewId(); + $date->author_id = $this->range_id; + $date->editor_id = $this->range_id; + $range_date = new CalendarDateAssignment(); + $range_date->range_id = $this->range_id; + $range_date->participation = ''; + $date->calendars[] = $range_date; + } + + $date->begin = $properties['DTSTART']->getTimestamp(); + $date->end = $properties['DTEND']->getTimestamp(); + $date->title = $properties['SUMMARY']; + $date->description = $properties['DESCRIPTION']; + $date->access = $properties['CLASS'] ?? 'PRIVATE'; + $date->user_category = $properties['CATEGORIES']; + $date->category = $properties['STUDIP_CATEGORY'] ?: 1; + $date->priority = $properties['PRIORITY'] ?? ''; + $date->location = $properties['LOCATION']; + if (is_array($properties['EXDATE'])) { + foreach ($properties['EXDATE'] as $exdate) { + $exception = new CalendarDateException(); + $exception->date = $exdate->format('Y-m-d'); + $date->exceptions[] = $exception; + } + } + $date->mkdate = $properties['CREATED'] ? $properties['CREATED']->getTimestamp() : time(); + if (isset($properties['LAST-MODIFIED'])) { + $date->chdate = $properties['LAST-MODIFIED']->getTimestamp(); + } else { + $date->chdate = $date->mkdate; + } + $date->import_date = $this->import_time; + $date->unique_id = $properties['UID']; + + $this->setRecurrenceRule($date, $properties['RRULE']); + $date->store(); + } + + private function setRecurrenceRule(CalendarDate $date, $rrule) + { + $date->interval = $rrule['linterval'] ?? 1; + if (strlen($rrule['wdays'] ?? '')) { + $date->offset = $rrule['sinterval'] ?? 0; + $date->days = $rrule['wdays'] ?? null; + } else { + $date->offset = $rrule['day'] ?? 0; + $date->days = $rrule['sinterval'] ?? null; + } + $date->month = $rrule['month'] ?? null; + $date->repetition_type = $rrule['rtype'] ?? 'SINGLE'; + $date->number_of_dates = $rrule['count'] ?? 1; + $date->repetition_end = $rrule['expire'] ?? 0; + } + + private function unfoldLine($data) + { + return preg_replace('/\x0D?\x0A[\x20\x09]/', '', $data); + } + + /** + * Parse a UTC Offset field + */ + private function parseUtcOffset($offset_text) + { + $offset = 0; + if (preg_match('/(\+|-)([0-9]{2})([0-9]{2})([0-9]{2})?/', $offset_text, $matches)) { + $offset += 3600 * intval($matches[2]); + $offset += 60 * intval($matches[3]); + $offset *= ( $matches[1] == '+' ? 1 : -1); + if (array_key_exists(4, $matches)) { + $offset += intval($matches[4]); + } + } + return $offset; + } + + /** + * Parse a Time Period field + */ + private function parsePeriod($period_text): array + { + $matches = explode('/', $period_text); + + $start = $this->parseDateTime($matches[0]); + + if ($duration = $this->parseDuration($matches[1])) { + return ['start' => $start, 'duration' => $duration]; + } else if ($end = $this->parseDateTime($matches[1])) { + return ['start' => $start, 'end' => $end]; + } + return []; + } + + /** + * Parse a DateTime field + */ + private function parseDateTime(String $date_time) + { + $parts = explode('T', $date_time); + if (count($parts) != 2) { + // not a date time string but may be just a date string + $date = $this->parseDate($date_time); + return DateTimeImmutable::createFromFormat('YmdHis', implode('', $date) . '000000'); + } + + $date = $this->parseDate($parts[0]); + $time = $this->parseTime($parts[1]); + + if ($time['zone'] == 'UTC') { + $time_zone = new DateTimeZone('UTC'); + } else { + $time_zone = new DateTimeZone('Europe/Berlin'); + } + return DateTimeImmutable::createFromFormat( + 'YmdHis', + implode('', $date) . $time['hour'] . $time['minute'] . $time['second'], + $time_zone + ); + } + + /** + * Parse a Time field + */ + private function parseTime($time_text): array + { + $matches = []; + if (preg_match('/([0-9]{2})([0-9]{2})([0-9]{2})(Z)?/', $time_text, $matches)) { + $time['hour'] = $matches[1]; + $time['minute'] = $matches[2]; + $time['second'] = $matches[3]; + if (array_key_exists(4, $matches)) { + $time['zone'] = 'UTC'; + } else { + $time['zone'] = 'LOCAL'; + } + return $time; + } + throw new InvalidValuesException(); + } + + /** + * Parse a Date field + */ + private function parseDate($date_text): array + { + $matches = []; + if (preg_match('/([0-9]{4})([0-9]{2})([0-9]{2})/', $date_text, $matches)) { + $date['year'] = $matches[1]; + $date['month'] = $matches[2]; + $date['mday'] = $matches[3]; + return $date; + } + throw new InvalidValuesException(); + } + + /** + * Parse a Duration Value field + */ + private function parseDuration($interval_text): DateInterval + { + return new DateInterval($interval_text); + } + + private function parsePriority($value) + { + $value = intval($value); + if ($value > 0 && $value < 5) { + return 'HIGH'; + } + + if ($value == 5) { + return 'MEDIUM'; + } + + if ($value > 5 && $value < 10) { + return 'LOW'; + } + + return ''; + } + + /** + * Parse a recurrence rule. + * + * @param $text string The text of the recurrence rule. + * @return array The translated recurrence rule as array. + * @throws InvalidValuesException + */ + private function parseRecurrence($text): array + { + global $_calendar_error; + + if (preg_match_all('/([A-Za-z]*?)=([^;]*);?/', $text, $matches, PREG_SET_ORDER)) { + $r_rule = []; + + foreach ($matches as $match) { + switch ($match[1]) { + case 'FREQ' : + switch (trim($match[2])) { + case 'DAILY' : + case 'WEEKLY' : + case 'MONTHLY' : + case 'YEARLY' : + $r_rule['rtype'] = trim($match[2]); + break; + default: + throw new InvalidValuesException( + _("Der Import enthält Kalenderdaten, die Stud.IP nicht korrekt darstellen kann.") + ); + } + break; + + case 'UNTIL' : + $r_rule['expire'] = $this->parseDateTime($match[2]); + break; + + case 'COUNT' : + $r_rule['count'] = intval($match[2]); + break; + + case 'INTERVAL' : + $r_rule['linterval'] = intval($match[2]); + break; + + case 'BYSECOND' : + case 'BYMINUTE' : + case 'BYHOUR' : + case 'BYWEEKNO' : + case 'BYYEARDAY' : + throw new InvalidValuesException( + _("Der Import enthält Kalenderdaten, die Stud.IP nicht korrekt darstellen kann.") + ); + case 'BYDAY' : + $byday = $this->parseByDay($match[2]); + $r_rule['wdays'] = $byday['wdays']; + if ($byday['sinterval']) + $r_rule['sinterval'] = $byday['sinterval']; + break; + + case 'BYMONTH' : + $r_rule['month'] = $this->parseByMonth($match[2]); + break; + + case 'BYMONTHDAY' : + $r_rule['day'] = $this->parseByMonthDay($match[2]); + break; + + case 'BYSETPOS': + $r_rule['sinterval'] = intval($match[2]); + break; + + case 'WKST' : + break; + } + } + } + + return $r_rule; + } + + private function parseByDay($text) + { + global $_calendar_error; + + preg_match_all('/(-?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU),?/', $text, $matches, PREG_SET_ORDER); + $wdays_map = ['MO' => '1', 'TU' => '2', 'WE' => '3', 'TH' => '4', 'FR' => '5', + 'SA' => '6', 'SU' => '7']; + $wdays = ""; + $sinterval = null; + foreach ($matches as $match) { + $wdays .= $wdays_map[$match[2]]; + if ($match[1]) { + if (!$sinterval && ((int) $match[1]) > 0 || $match[1] == '-1') { + if ($match[1] == '-1') { + $sinterval = '5'; + } else { + $sinterval = $match[1]; + } + } else { + throw new InvalidValuesException( + _("Der Import enthält Kalenderdaten, die Stud.IP nicht korrekt darstellen kann.") + ); + } + } + } + + return $wdays ? ['wdays' => $wdays, 'sinterval' => $sinterval] : false; + } + + private function parseByMonthDay($text) + { + $days = explode(',', $text); + if (count($days) > 1 || ((int) $days[0]) < 0) { + return false; + } + + return $days[0]; + } + + private function parseByMonth($text) + { + $months = explode(',', $text); + if (count($months) > 1) { + return false; + } + + return $months[0]; + } + + private function qp_decode($value) + { + return preg_replace_callback("/=([0-9A-F]{2})/", function ($m) {return chr(hexdec($m[1]));}, $value); + } + + private function parseClientIdentifier(&$data) + { + global $_calendar_error; + + if ($this->client_identifier == '') { + if (!preg_match('/PRODID((;[\W\w]*)*):([\W\w]+?)(\r\n|\r|\n)/', $data, $matches) + || !trim($matches[3])) { + // _("Die Datei ist keine gültige iCalendar-Datei!") + throw new InvalidValuesException(); + } else { + $this->client_identifier = trim($matches[3]); + } + } + return true; + } + + public function getClientIdentifier($data = null) + { + if (!is_null($data)) { + $this->parseClientIdentifier($data); + } + + return $this->client_identifier; + } + +} |
