diff --git a/src/app/Backends/DAV/Vtodo.php b/src/app/Backends/DAV/Vtodo.php index 9ae1bfc6..760fa559 100644 --- a/src/app/Backends/DAV/Vtodo.php +++ b/src/app/Backends/DAV/Vtodo.php @@ -1,53 +1,42 @@ {$prop})) { - $key = Str::camel(strtolower($prop)); - $this->{$key} = (string) $vobject->{$prop}; - } - } - // map other properties foreach ($vobject->children() as $prop) { if (!($prop instanceof Property)) { continue; } switch ($prop->name) { case 'DUE': // This is of type Sabre\VObject\Property\ICalendar\DateTime $this->due = $prop; break; case 'PERCENT-COMPLETE': $this->percentComplete = $prop->getValue(); break; } } } } diff --git a/src/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php index 33b2a7a6..76dc6b4a 100644 --- a/src/app/DataMigrator/EWS/Task.php +++ b/src/app/DataMigrator/EWS/Task.php @@ -1,319 +1,319 @@ [$this->getUID($item)], 'DTSTAMP' => [$this->formatDate($item->getLastModifiedTime()), ['VALUE' => 'DATE-TIME']], 'CREATED' => [$this->formatDate($item->getDateTimeCreated()), ['VALUE' => 'DATE-TIME']], 'SEQUENCE' => [intval($item->getChangeCount())], 'SUMMARY' => [$item->getSubject()], 'DESCRIPTION' => [(string) $item->getBody()], 'PERCENT-COMPLETE' => [intval($item->getPercentComplete())], 'X-MS-ID' => [$this->itemId], ]; if ($dueDate = $item->getDueDate()) { $data['DUE'] = [$this->formatDate($dueDate), ['VALUE' => 'DATE-TIME']]; } if ($startDate = $item->getStartDate()) { $data['DTSTART'] = [$this->formatDate($startDate), ['VALUE' => 'DATE-TIME']]; } if ($status = $item->getStatus()) { $status = strtoupper($status); $status_map = [ 'COMPLETED' => 'COMPLETED', 'INPROGRESS' => 'IN-PROGRESS', 'DEFERRED' => 'X-DEFERRED', 'NOTSTARTED' => 'X-NOTSTARTED', 'WAITINGONOTHERS' => 'X-WAITINGFOROTHERS', ]; if (isset($status_map[$status])) { $data['STATUS'] = [$status_map[$status]]; } } 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']; } if ($importance = $item->getImportance()) { $importance_map = [ 'HIGH' => '9', 'NORMAL' => '5', 'LOW' => '1', ]; $data['PRIORITY'] = [$importance_map[strtoupper($importance)] ?? '0']; } $this->setTaskOrganizer($data, $item); $this->setTaskRecurrence($data, $item); $ical = "BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nVERSION:2.0\r\nPRODID:Kolab EWS Data Migrator\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:VTODO\r\n"; $ical .= "END:VCALENDAR\r\n"; return $ical; } /** * 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->driver->getSourceAccount(); $destination = $this->driver->getDestinationAccount(); 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; } elseif ($recurrence = $r->getWeeklyRecurrence()) { $rrule['FREQ'] = 'WEEKLY'; $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek()); $rrule['WKST'] = $this->mapDays($recurrence->getFirstDayOfWeek()); } elseif ($recurrence = $r->getAbsoluteMonthlyRecurrence()) { $rrule['FREQ'] = 'MONTHLY'; $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth(); } elseif ($recurrence = $r->getRelativeMonthlyRecurrence()) { $rrule['FREQ'] = 'MONTHLY'; $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1; $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex()); } elseif ($recurrence = $r->getAbsoluteYearlyRecurrence()) { $rrule['FREQ'] = 'YEARLY'; $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth()); $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth(); } elseif ($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 \Log::debug("[EWS] Unsupported Recurrence property value. Ignored."); } if (!empty($rrule)) { if ($recurrence = $r->getNumberedRecurrence()) { $rrule['COUNT'] = $recurrence->getNumberOfOccurrences(); } elseif ($recurrence = $r->getEndDateRecurrence()) { $rrule['UNTIL'] = $this->formatDate($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 = $this->formatDate($task->getReminderDueBy()); if (empty($task->getReminderIsSet()) || empty($date)) { return ''; } - return "BEGIN:VALARM\r\nACTION:DISPLAY\r\n" - . "TRIGGER;VALUE=DATE-TIME:{$date}\r\n" + return "BEGIN:VALARM\r\n" + . "ACTION:DISPLAY\r\nTRIGGER;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); } /** * Format EWS date-time into a iCalendar date-time */ protected function formatDate($datetime) { if (empty($datetime)) { return null; } return str_replace(['Z', '-', ':'], '', $datetime); } } diff --git a/src/tests/Unit/Backends/DAV/VeventTest.php b/src/tests/Unit/Backends/DAV/VeventTest.php index 1548945b..5ad3c585 100644 --- a/src/tests/Unit/Backends/DAV/VeventTest.php +++ b/src/tests/Unit/Backends/DAV/VeventTest.php @@ -1,81 +1,81 @@ /dav/calendars/user/test@test.com/Default/$uid.ics "d27382e0b401384becb0d5b157d6b73a2c2084a2" HTTP/1.1 200 OK XML; $doc = new \DOMDocument('1.0', 'UTF-8'); $doc->loadXML($ical); $event = Vevent::fromDomElement($doc->getElementsByTagName('response')->item(0)); $this->assertInstanceOf(Vevent::class, $event); $this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $event->etag); $this->assertSame("/dav/calendars/user/test@test.com/Default/{$uid}.ics", $event->href); $this->assertSame('text/calendar; charset=utf-8', $event->contentType); $this->assertSame($uid, $event->uid); $this->assertSame('My summary', $event->summary); $this->assertSame('desc', $event->description); $this->assertSame('OPAQUE', $event->transp); // TODO: Should we make these Sabre\VObject\Property\ICalendar\DateTime properties $this->assertSame('20221016T103238Z', (string) $event->dtstamp); $this->assertSame('20221013', (string) $event->dtstart); $organizer = [ 'rsvp' => false, 'email' => 'organizer@test.com', 'role' => 'ORGANIZER', 'partstat' => 'ACCEPTED', ]; $this->assertSame($organizer, $event->organizer); $recurrence = [ 'freq' => 'WEEKLY', 'interval' => 1, ]; - $this->assertSame($recurrence, $event->recurrence); + $this->assertSame($recurrence, $event->rrule); // TODO: Test all supported properties in detail } } diff --git a/src/tests/Unit/DataMigrator/EWS/TaskTest.php b/src/tests/Unit/DataMigrator/EWS/TaskTest.php index e7f118a7..43b72474 100644 --- a/src/tests/Unit/DataMigrator/EWS/TaskTest.php +++ b/src/tests/Unit/DataMigrator/EWS/TaskTest.php @@ -1,158 +1,161 @@ source = $source; $engine->destination = $destination; $ews = new EWS($source, $engine); $folder = Folder::fromArray(['id' => 'test']); $task = new EWS\Task($ews, $folder); // FIXME: I haven't found a way to convert xml content into a Type instance // therefore we create it "manually", but it would be better to have it in XML. $html = '' . '
task notes
'; $item = Type\TaskType::buildFromArray([ 'ItemId' => new Type\ItemIdType( 'AAMkAGEzOGRlODRiLTBkN2ItNDgwZS04ZDJmLTM5NDEyY2Q0NGQ0OABGAAAAAAC9tlDYSlG2TaxWBr' . 'A1OzWtBwAs2ajhknXlRYN/pbC8JqblAAAAAAEOAAAs2ajhknXlRYN/pbC8JqblAAJnrWkBAAA=', 'EQAAABYAAAAs2ajhknXlRYN/pbC8JqblAAJnqlKm', ), 'ItemClass' => 'IPM.Task', 'Subject' => 'Nowe zadanie', 'LastModifiedTime' => '2024-06-27T13:44:32Z', 'Sensitivity' => 'Private', // TODO: Looks like EWS has Body->IsTruncated property, but is it relevant? 'Body' => new Type\BodyType($html, 'HTML'), 'Importance' => 'High', 'DateTimeCreated' => '2024-06-27T08:58:05Z', 'ReminderDueBy' => '2024-07-17T07:00:00Z', 'ReminderIsSet' => true, 'ReminderNextTime' => '2024-07-17T07:00:00Z', 'ReminderMinutesBeforeStart' => '0', 'DueDate' => '2024-06-26T22:00:00Z', 'IsComplete' => false, 'IsRecurring' => true, 'Owner' => 'Alec Machniak', 'PercentComplete' => '10', 'Recurrence' => Type\TaskRecurrenceType::buildFromArray([ 'WeeklyRecurrence' => [ 'Interval' => '1', 'DaysOfWeek' => 'Thursday', 'FirstDayOfWeek' => 'Sunday', ], 'NoEndRecurrence' => [ 'StartDate' => '2024-06-27Z', ], ]), 'Status' => 'NotStarted', 'ChangeCount' => '2', /* testdisk.log application/octet-stream 299368b3-06e4-42df-959e-d428046f55e6 249 2024-07-16T12:13:58 false false 2024-06-27T08:58:05Z 3041 Kategoria Niebieski false false false false false 2024-06-27T08:58:05Z true en-US false false false true true true true Alec Machniak 2024-07-16T12:14:38Z false NotFlagged AQEAAAAAAAESAQAAAmjiC08AAAAA 1 Not Started */ ]); // Convert the Exchange item into iCalendar $ical = $this->invokeMethod($task, 'processItem', [$item]); // Parse the iCalendar output $task = new Vtodo(); $this->invokeMethod($task, 'fromIcal', [$ical]); $msId = implode('!', $item->getItemId()->toArray()); $this->assertSame($msId, $task->custom['X-MS-ID']); $this->assertMatchesRegularExpression('/^[a-f0-9]{40}$/', $task->uid); $this->assertSame('Nowe zadanie', $task->summary); $this->assertSame($html, $task->description); $this->assertSame('Kolab EWS Data Migrator', $task->prodid); $this->assertSame('2', $task->sequence); $this->assertSame('9', $task->priority); $this->assertSame('PRIVATE', $task->class); $this->assertSame(10, $task->percentComplete); $this->assertSame('X-NOTSTARTED', $task->status); $this->assertSame('2024-06-27T13:44:32+00:00', $task->dtstamp->getDateTime()->format('c')); $this->assertSame('2024-06-27T08:58:05+00:00', $task->created->getDateTime()->format('c')); $this->assertSame('2024-06-26T22:00:00+00:00', $task->due->getDateTime()->format('c')); $this->assertSame('test@kolab.org', $task->organizer['email']); $this->assertSame('WEEKLY', $task->rrule['freq']); $this->assertSame('1', $task->rrule['interval']); $this->assertSame('TH', $task->rrule['byday']); $this->assertSame('SU', $task->rrule['wkst']); - // TODO: Reminder + $this->assertCount(1, $task->valarms); + $this->assertCount(2, $task->valarms[0]); + $this->assertSame('DISPLAY', $task->valarms[0]['action']); + $this->assertSame('2024-07-17T07:00:00+00:00', $task->valarms[0]['trigger']->format('c')); } /** * Test processing Recurrence property */ public function testProcessItemRecurrence(): void { $this->markTestIncomplete(); } }