diff --git a/src/app/DataMigrator/EWS/DistList.php b/src/app/DataMigrator/EWS/DistList.php index 37570127..ed0316a2 100644 --- a/src/app/DataMigrator/EWS/DistList.php +++ b/src/app/DataMigrator/EWS/DistList.php @@ -1,64 +1,63 @@ $this->getUID($item), - "KIND" => "group", - "FN" => $item->getDisplayName(), - "REV;VALUE=DATE-TIME" => $item->getLastModifiedTime(), + "UID" => [$this->getUID($item)], + "KIND" => ["group"], + "FN" => [$item->getDisplayName()], + "REV" => [$item->getLastModifiedTime(), ['VALUE' => 'DATE-TIME']], ]; $vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:Kolab EWS DataMigrator\r\n"; - foreach ($data as $key => $value) { - // TODO: value wrapping/escaping - $vcard .= "{$key}:{$value}\r\n"; + foreach ($data as $key => $prop) { + $vcard .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []); } // Process list members // Note: The fact that getMembers() returns stdClass is probably a bug in php-ews foreach ($item->getMembers()->Member as $member) { $mailbox = $member->getMailbox(); $mailto = $mailbox->getEmailAddress(); $name = $mailbox->getName(); // FIXME: Investigate if mailto: members are handled properly by Kolab // or we need to use MEMBER:urn:uuid:9bd97510-9dbb-4810-a144-6180962df5e0 syntax // But do not forget lists can have members that are not contacts if ($mailto) { if ($name && $name != $mailto) { $mailto = urlencode(sprintf('"%s" <%s>', addcslashes($name, '"'), $mailto)); } - $vcard .= "MEMBER:mailto:{$mailto}\r\n"; + $vcard .= $this->formatProp('MEMBER', "mailto:{$mailto}"); } } $vcard .= "END:VCARD"; // TODO: Maybe find less-hacky way $item->setMimeContent((new Type\MimeContentType)->set('_', $vcard)); return true; } } diff --git a/src/app/DataMigrator/EWS/Item.php b/src/app/DataMigrator/EWS/Item.php index 1dec3996..ee17295f 100644 --- a/src/app/DataMigrator/EWS/Item.php +++ b/src/app/DataMigrator/EWS/Item.php @@ -1,134 +1,145 @@ 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); + + return $prop->serialize(); + } } diff --git a/src/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php index 73054061..2b1dfdaa 100644 --- a/src/app/DataMigrator/EWS/Task.php +++ b/src/app/DataMigrator/EWS/Task.php @@ -1,111 +1,110 @@ $this->getUID($item), - "DTSTAMP;VALUE=DATE-TIME" => $item->getLastModifiedTime(), - "CREATED;VALUE=DATE-TIME" => $item->getDateTimeCreated(), - "SEQUENCE" => '0', // TODO - "SUMMARY" => $item->getSubject(), - "DESCRIPTION" => (string) $item->getBody(), - "PERCENT-COMPLETE" => intval($item->getPercentComplete()), - "STATUS" => strtoupper($item->getStatus()), + "UID" => [$this->getUID($item)], + "DTSTAMP" => [$item->getLastModifiedTime(), ['VALUE' => 'DATE-TIME']], + "CREATED" => [$item->getDateTimeCreated(), ['VALUE' => 'DATE-TIME']], + "SEQUENCE" => ['0'], // TODO + "SUMMARY" => [$item->getSubject()], + "DESCRIPTION" => [(string) $item->getBody()], + "PERCENT-COMPLETE" => [intval($item->getPercentComplete())], + "STATUS" => [strtoupper($item->getStatus())], ]; if ($dueDate = $item->getDueDate()) { - $data["DUE:VALUE=DATE-TIME"] = $dueDate; + $data["DUE"] = [$dueDate, ['VALUE' => 'DATE-TIME']]; } if ($startDate = $item->getStartDate()) { - $data["DTSTART:VALUE=DATE-TIME"] = $startDate; + $data["DTSTART"] = [$startDate, ['VALUE' => 'DATE-TIME']]; } if (($categories = $item->getCategories()) && $categories->String) { - $data["CATEGORIES"] = $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']; } // TODO: VTIMEZONE, VALARM, ORGANIZER, ATTENDEE, RRULE, // TODO: PRIORITY (Importance) - not used by Kolab // ReminderDueBy, ReminderIsSet, IsRecurring, Owner, Recurrence $ical = "BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nVERSION:2.0\r\nPRODID:Kolab EWS DataMigrator\r\nBEGIN:VTODO\r\n"; - foreach ($data as $key => $value) { - // TODO: value wrapping/escaping - $ical .= "{$key}:{$value}\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); // 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($_attachment->getContent()); $body = rtrim(chunk_split($body, 74, "\r\n "), ' '); $ctype = $_attachment->getContentType(); // 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}\r\n"; } } $ical .= "END:VEVENT\r\n"; $ical .= "END:VCALENDAR"; // TODO: Maybe find less-hacky way $item->setMimeContent((new Type\MimeContentType)->set('_', $ical)); return true; } }