diff --git a/src/app/DataMigrator/EWS/Item.php b/src/app/DataMigrator/EWS/Item.php index ee17295f..a2e154bd 100644 --- a/src/app/DataMigrator/EWS/Item.php +++ b/src/app/DataMigrator/EWS/Item.php @@ -1,145 +1,152 @@ engine = $engine; $this->folder = $folder; } /** * Factory method. * Returns object suitable to handle specified item type. */ public static function factory(EWS $engine, Type $item, array $folder) { $item_class = str_replace('IPM.', '', $item->getItemClass()); $item_class = "\App\DataMigrator\EWS\\{$item_class}"; if (class_exists($item_class)) { return new $item_class($engine, $folder); } } /** * Synchronize specified object */ public function syncItem(Type $item): void { // Fetch the item $item = $this->engine->api->getItem($item->getItemId(), $this->getItemRequest()); $uid = $this->getUID($item); $this->engine->debug("* Saving item {$uid}..."); // Apply type-specific format converters if ($this->processItem($item) === false) { return; } $uid = preg_replace('/[^a-zA-Z0-9_:@-]/', '', $uid); $location = $this->folder['location']; if (!file_exists($location)) { mkdir($location, 0740, true); } $location .= '/' . $uid . '.' . $this::FILE_EXT; file_put_contents($location, (string) $item->getMimeContent()); } /** * Item conversion code */ abstract protected function processItem(Type $item): bool; /** * Get GetItem request parameters */ protected function getItemRequest(): array { $request = [ 'ItemShape' => [ // Reqest default set of properties 'BaseShape' => 'Default', // Additional properties, e.g. LastModifiedTime // FIXME: How to add multiple properties here? 'AdditionalProperties' => [ 'FieldURI' => ['FieldURI' => 'item:LastModifiedTime'], ] ] ]; return $request; } /** * Fetch attachment object from Exchange */ protected function getAttachment(Type\FileAttachmentType $attachment) { $request = [ 'AttachmentIds' => [ $attachment->getAttachmentId()->toXmlObject() ], 'AttachmentShape' => [ 'IncludeMimeContent' => true, ] ]; return $this->engine->api->getClient()->GetAttachment($request); } /** * Get Item UID (Generate a new one if needed) */ protected function getUID(Type $item): string { if ($this->uid === null) { // We should generate an UID for objects that do not have it // and inject it into the output file // FIXME: Should we use e.g. md5($itemId->getId()) instead? $this->uid = \App\Utils::uuidStr(); } return $this->uid; } /** * VCard/iCal property formatting */ protected function formatProp($name, $value, array $params = []): string { $cal = new \Sabre\VObject\Component\VCalendar(); - $prop = new \Sabre\VObject\Property\Text($cal, $name, $value); + $prop = new \Sabre\VObject\Property\Text($cal, $name, $value, $params); - return $prop->serialize(); + $value = $prop->serialize(); + + // Revert escaping for some props + if ($name == 'RRULE') { + $value = str_replace("\\", '', $value); + } + + return $value; } } diff --git a/src/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php index 0703f1e5..b72d57c8 100644 --- a/src/app/DataMigrator/EWS/Task.php +++ b/src/app/DataMigrator/EWS/Task.php @@ -1,117 +1,293 @@ [$this->getUID($item)], - "DTSTAMP" => [$item->getLastModifiedTime(), ['VALUE' => 'DATE-TIME']], - "CREATED" => [$item->getDateTimeCreated(), ['VALUE' => 'DATE-TIME']], - "SEQUENCE" => ['0'], // TODO + "DTSTAMP" => [$item->getLastModifiedTime()], + "CREATED" => [$item->getDateTimeCreated()], + "SEQUENCE" => [intval($item->getChangeCount())], "SUMMARY" => [$item->getSubject()], "DESCRIPTION" => [(string) $item->getBody()], "PERCENT-COMPLETE" => [intval($item->getPercentComplete())], "STATUS" => [strtoupper($item->getStatus())], ]; if ($dueDate = $item->getDueDate()) { - $data["DUE"] = [$dueDate, ['VALUE' => 'DATE-TIME']]; + $data["DUE"] = [$dueDate]; } if ($startDate = $item->getStartDate()) { - $data["DTSTART"] = [$startDate, ['VALUE' => 'DATE-TIME']]; + $data["DTSTART"] = [$startDate]; } if (($categories = $item->getCategories()) && $categories->String) { $data["CATEGORIES"] = [$categories->String]; } if ($sensitivity = $item->getSensitivity()) { $sensitivity_map = [ 'CONFIDENTIAL' => 'CONFIDENTIAL', 'NORMAL' => 'PUBLIC', 'PERSONAL' => 'PUBLIC', 'PRIVATE' => 'PRIVATE', ]; - $data['CLASS'] = [$sensitivity_map[strtoupper($sensitivity)] ?: 'PUBLIC']; + $data["CLASS"] = [$sensitivity_map[strtoupper($sensitivity)] ?: 'PUBLIC']; + } + + if ($importance = $item->getImportance()) { + $importance_map = [ + 'HIGH' => '9', + 'NORMAL' => '5', + 'LOW' => '1', + ]; + + $data["PRIORITY"] = [$importance_map[strtoupper($importance)] ?: '0']; } - // TODO: VTIMEZONE, VALARM, ORGANIZER, ATTENDEE, RRULE, - // TODO: PRIORITY (Importance) - not used by Kolab - // ReminderDueBy, ReminderIsSet, IsRecurring, Owner, Recurrence + $this->setTaskOrganizer($data, $item); + $this->setTaskRecurrence($data, $item); $ical = "BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nVERSION:2.0\r\nPRODID:Kolab EWS DataMigrator\r\nBEGIN:VTODO\r\n"; foreach ($data as $key => $prop) { $ical .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []); } // Attachments if ($item->getHasAttachments()) { foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) { $_attachment = $this->getAttachment($attachment); $ctype = $_attachment->getContentType(); $body = $_attachment->getContent(); // It looks like Exchange may have an issue with plain text files. // We'll skip empty files if (!strlen($body)) { continue; } // FIXME: This is imo inconsistence on php-ews side that MimeContent // is base64 encoded, but Content isn't // TODO: We should not do it in memory to not exceed the memory limit $body = base64_encode($body); $body = rtrim(chunk_split($body, 74, "\r\n "), ' '); // Inject the attachment at the end of the VTODO block // TODO: We should not do it in memory to not exceed the memory limit $ical .= "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}"; } } + $ical .= $this->getVAlarm($item); $ical .= "END:VEVENT\r\n"; $ical .= "END:VCALENDAR\r\n"; // TODO: Maybe find less-hacky way $item->setMimeContent((new Type\MimeContentType)->set('_', $ical)); return true; } + + /** + * Set task organizer/attendee + */ + protected function setTaskOrganizer(array &$data, Type $task) + { + // FIXME: Looks like the owner might be an email address or just a full user name + $owner = $task->getOwner(); + $source = $this->engine->getSource(); + $destination = $this->engine->getDestination(); + + if (strpos($owner, '@') && $owner != $source->email) { + // Task owned by another person + $data["ORGANIZER"] = ["mailto:{$owner}"]; + + // FIXME: Because attendees are not specified in EWS, assume the user is an attendee + if ($destination->email) { + $params = ["ROLE" => "REQ-PARTICIPANT", "CUTYPE" => "INDIVIDUAL"]; + $data["ATTENDEE"] = ["mailto:{$destination->email}", $params]; + } + + return; + } + + // Otherwise it must be owned by the user + if ($destination->email) { + $data["ORGANIZER"] = ["mailto:{$destination->email}"]; + } + } + + /** + * Set task recurrence rule + */ + protected function setTaskRecurrence(array &$data, Type $task) + { + if (empty($task->getIsRecurring()) || empty($task->getRecurrence())) { + return; + } + + $r = $task->getRecurrence(); + $rrule = []; + + if ($recurrence = $r->getDailyRecurrence()) { + $rrule['FREQ'] = 'DAILY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + } else if ($recurrence = $r->getWeeklyRecurrence()) { + $rrule['FREQ'] = 'WEEKLY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek()); + $rrule['WKST'] = $this->mapDays($recurrence->getFirstDayOfWeek()); + } else if ($recurrence = $r->getAbsoluteMonthlyRecurrence()) { + $rrule['FREQ'] = 'MONTHLY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth(); + } else if ($recurrence = $r->getRelativeMonthlyRecurrence()) { + $rrule['FREQ'] = 'MONTHLY'; + $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; + $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex()); + } else if ($recurrence = $r->getAbsoluteYearlyRecurrence()) { + $rrule['FREQ'] = 'YEARLY'; + $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth()); + $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth(); + } else if ($recurrence = $r->getRelativeYearlyRecurrence()) { + $rrule['FREQ'] = 'YEARLY'; + $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth()); + $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex()); + } else { + // There might be *Regeneration rules that we don't support + $this->engine->debug("Unsupported Recurrence property value. Ignored."); + } + + if (!empty($rrule)) { + if ($recurrence = $r->getNumberedRecurrence()) { + $rrule['COUNT'] = $recurrence->getNumberOfOccurrences(); + } else if ($recurrence = $r->getEndDateRecurrence()) { + $rrule['UNTIL'] = str_replace('Z', '', $recurrence->getEndDate()); + } + + $rrule = array_filter($rrule); + $rrule = trim(array_reduce( + array_keys($rrule), + function ($carry, $key) use ($rrule) { + return $carry . ';' . $key . '=' . $rrule[$key]; + } + ), ';'); + + $data["RRULE"] = [$rrule]; + } + } + + /** + * Get VALARM block for the task Reminder + */ + protected function getVAlarm(Type $task): string + { + // FIXME: To me it looks like ReminderMinutesBeforeStart property is not used + + $date = $task->getReminderDueBy(); + + if (empty($task->getReminderIsSet()) || empty($date)) { + return ''; + } + + return "BEGIN:VALARM\r\nACTION:DISPLAY\r\n" + . "TRIGGER;VALUE=DATE-TIME:{$date}\r\n" + . "END:VALARM\r\n"; + } + + /** + * Convert EWS representation of recurrence days to iCal + */ + protected function mapDays(string $days, string $index = ''): string + { + if (preg_match('/(Day|Weekday|WeekendDay)/', $days)) { + // not supported + return ''; + } + + $days_map = [ + 'Sunday' => 'SU', + 'Monday' => 'MO', + 'Tuesday' => 'TU', + 'Wednesday' => 'WE', + 'Thursday' => 'TH', + 'Friday' => 'FR', + 'Saturday' => 'SA', + ]; + + $index_map = [ + 'First' => 1, + 'Second' => 2, + 'Third' => 3, + 'Fourth' => 4, + 'Last' => -1, + ]; + + $days = explode(' ', $days); + $days = array_map(function($day) use ($days_map, $index_map, $index) { + return ($index ? $index_map[$index] : '') . $days_map[$day]; + }, + $days + ); + + return implode(',', $days); + } + + /** + * Convert EWS representation of recurrence month to iCal + */ + protected function mapMonths(string $months): string + { + $months_map = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + + $months = explode(' ', $months); + $months = array_map(function($month) use ($months_map) { + return array_search($month, $months_map) + 1; + }, + $months + ); + + return implode(',', $months); + } }