diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index a438a4dd06346..d2a4261fd3523 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -65,6 +65,8 @@ OCA\DAV\Command\SyncBirthdayCalendar OCA\DAV\Command\SyncSystemAddressBook OCA\DAV\Command\RemoveInvalidShares + OCA\DAV\Command\ImportCalendar + OCA\DAV\Command\ExportCalendar diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index a70aba9f84218..526e37eee14fd 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -63,9 +63,13 @@ 'OCA\\DAV\\CalDAV\\EventReader' => $baseDir . '/../lib/CalDAV/EventReader.php', 'OCA\\DAV\\CalDAV\\EventReaderRDate' => $baseDir . '/../lib/CalDAV/EventReaderRDate.php', 'OCA\\DAV\\CalDAV\\EventReaderRRule' => $baseDir . '/../lib/CalDAV/EventReaderRRule.php', + 'OCA\\DAV\\CalDAV\\Export\\ExportService' => $baseDir . '/../lib/CalDAV/Export/ExportService.php', 'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php', 'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php', 'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportService' => $baseDir . '/../lib/CalDAV/Import/ImportService.php', + 'OCA\\DAV\\CalDAV\\Import\\TextImporter' => $baseDir . '/../lib/CalDAV/Import/TextImporter.php', + 'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php', 'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php', 'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => $baseDir . '/../lib/CalDAV/Integration/ICalendarProvider.php', 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => $baseDir . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php', @@ -157,7 +161,9 @@ 'OCA\\DAV\\Command\\CreateCalendar' => $baseDir . '/../lib/Command/CreateCalendar.php', 'OCA\\DAV\\Command\\CreateSubscription' => $baseDir . '/../lib/Command/CreateSubscription.php', 'OCA\\DAV\\Command\\DeleteCalendar' => $baseDir . '/../lib/Command/DeleteCalendar.php', + 'OCA\\DAV\\Command\\ExportCalendar' => $baseDir . '/../lib/Command/ExportCalendar.php', 'OCA\\DAV\\Command\\FixCalendarSyncCommand' => $baseDir . '/../lib/Command/FixCalendarSyncCommand.php', + 'OCA\\DAV\\Command\\ImportCalendar' => $baseDir . '/../lib/Command/ImportCalendar.php', 'OCA\\DAV\\Command\\ListAddressbooks' => $baseDir . '/../lib/Command/ListAddressbooks.php', 'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php', 'OCA\\DAV\\Command\\MoveCalendar' => $baseDir . '/../lib/Command/MoveCalendar.php', @@ -218,6 +224,8 @@ 'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', + 'OCA\\DAV\\Controller\\CalendarExportController' => $baseDir . '/../lib/Controller/CalendarExportController.php', + 'OCA\\DAV\\Controller\\CalendarImportController' => $baseDir . '/../lib/Controller/CalendarImportController.php', 'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => $baseDir . '/../lib/Controller/OutOfOfficeController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 2d00105b54892..a65d20b2fcb5d 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -78,9 +78,13 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\EventReader' => __DIR__ . '/..' . '/../lib/CalDAV/EventReader.php', 'OCA\\DAV\\CalDAV\\EventReaderRDate' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRDate.php', 'OCA\\DAV\\CalDAV\\EventReaderRRule' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRRule.php', + 'OCA\\DAV\\CalDAV\\Export\\ExportService' => __DIR__ . '/..' . '/../lib/CalDAV/Export/ExportService.php', 'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php', 'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php', 'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportService.php', + 'OCA\\DAV\\CalDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/TextImporter.php', + 'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php', 'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php', 'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ICalendarProvider.php', 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => __DIR__ . '/..' . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php', @@ -172,7 +176,9 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Command\\CreateCalendar' => __DIR__ . '/..' . '/../lib/Command/CreateCalendar.php', 'OCA\\DAV\\Command\\CreateSubscription' => __DIR__ . '/..' . '/../lib/Command/CreateSubscription.php', 'OCA\\DAV\\Command\\DeleteCalendar' => __DIR__ . '/..' . '/../lib/Command/DeleteCalendar.php', + 'OCA\\DAV\\Command\\ExportCalendar' => __DIR__ . '/..' . '/../lib/Command/ExportCalendar.php', 'OCA\\DAV\\Command\\FixCalendarSyncCommand' => __DIR__ . '/..' . '/../lib/Command/FixCalendarSyncCommand.php', + 'OCA\\DAV\\Command\\ImportCalendar' => __DIR__ . '/..' . '/../lib/Command/ImportCalendar.php', 'OCA\\DAV\\Command\\ListAddressbooks' => __DIR__ . '/..' . '/../lib/Command/ListAddressbooks.php', 'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php', 'OCA\\DAV\\Command\\MoveCalendar' => __DIR__ . '/..' . '/../lib/Command/MoveCalendar.php', @@ -233,6 +239,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', + 'OCA\\DAV\\Controller\\CalendarExportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarExportController.php', + 'OCA\\DAV\\Controller\\CalendarImportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarImportController.php', 'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => __DIR__ . '/..' . '/../lib/Controller/OutOfOfficeController.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 0c8b52a7491c7..d2606bb140d33 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -9,6 +9,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use Generator; use OCA\DAV\AppInfo\Application; use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\Connector\Sabre\Principal; @@ -34,6 +35,7 @@ use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Events\SubscriptionUpdatedEvent; use OCP\AppFramework\Db\TTransactional; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Exceptions\CalendarException; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -950,6 +952,35 @@ public function restoreCalendar(int $id): void { }, $this->db); } + /** + * Returns all calendar entries as a stream of data + * + * @since 32.0.0 + * + * @return Generator + */ + public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + if ($options?->rangeStart !== null) { + $qb->setFirstResult($options?->rangeStart); + } + if ($options?->rangeCount !== null) { + $qb->setMaxResults($options->rangeCount); + } + $rs = $qb->executeQuery(); + + while (($row = $rs->fetch()) !== false) { + yield $row; + } + + $rs->closeCursor(); + } + /** * Returns all calendar objects with limited metadata for a calendar * diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index b3062f005ee27..d45f7d59ec907 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -8,9 +8,17 @@ */ namespace OCA\DAV\CalDAV; +use Generator; +use InvalidArgumentException; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\CalendarImportOptions; use OCP\Calendar\Exceptions\CalendarException; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\ICalendarImport; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; use OCP\Calendar\IHandleImipMessage; use OCP\Constants; @@ -20,11 +28,14 @@ use Sabre\VObject\Component\VEvent; use Sabre\VObject\Component\VTimeZone; use Sabre\VObject\ITip\Message; +use Sabre\VObject\Node; use Sabre\VObject\Property; use Sabre\VObject\Reader; +use Sabre\VObject\UUIDUtil; + use function Sabre\Uri\split as uriSplit; -class CalendarImpl implements ICreateFromString, IHandleImipMessage { +class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarImport, ICalendarExport { public function __construct( private Calendar $calendar, /** @var array */ @@ -257,4 +268,123 @@ public function handleIMipMessage(string $name, string $calendarData): void { public function getInvitationResponseServer(): InvitationResponseServer { return new InvitationResponseServer(false); } + + /** + * Export objects + * + * @since 32.0.0 + * + * @return Generator + */ + public function export(?CalendarExportOptions $options = null): Generator { + foreach ( + $this->backend->exportCalendar( + $this->calendarInfo['id'], + $this->backend::CALENDAR_TYPE_CALENDAR, + $options + ) as $event + ) { + yield Reader::read($event['calendardata']); + } + } + + /** + * Import objects + * + * @since 32.0.0 + * + * @return array + */ + public function import(CalendarImportOptions $options, callable $generator): array { + + $calendarId = $this->getKey(); + $outcome = []; + foreach ($generator($options) as $vObject) { + + $components = $vObject->getBaseComponents(); + // determine if the object has no base component types + if (count($components) === 0) { + if ($options->errors === 1) { + throw new InvalidArgumentException('Error importing calendar object, discovered object with no base component types'); + } + $outcome['nbct'] = ['outcome' => 'error', 'errors' => ['One or more objects discovered with no base component types']]; + continue; + } + // determine if the object has more than one base component type + if (count($components) > 1) { + if ($options->errors === 1) { + throw new InvalidArgumentException('Error importing calendar object, discovered object with multiple base component types'); + } + $outcome['mbct'] = ['outcome' => 'error', 'errors' => ['One or more objects discovered with multiple base component types']]; + continue; + } + // determine if the object has a uid + if (!isset($components[0]->UID)) { + if ($options->errors === 1) { + throw new InvalidArgumentException('Error importing calendar object, discovered object without a UID'); + } + $outcome['noid'] = ['outcome' => 'error', 'errors' => ['One or more objects discovered without a UID']]; + continue; + } + $uid = $components[0]->UID->getValue(); + // validate object + if ($options->validate !== 0) { + $issues = $this->validateComponent($vObject, true, 3); + if ($options->validate === 1 && $issues !== []) { + $outcome[$uid] = ['outcome' => 'error', 'errors' => $issues]; + continue; + } elseif ($options->validate === 2 && $issues !== []) { + throw new InvalidArgumentException('Error importing calendar object <' . $uid . '>, ' . $issues[0]); + } + } + + $objectId = $this->backend->getCalendarObjectByUID($this->calendarInfo['principaluri'], $uid); + $objectData = $vObject->serialize(); + + // create or update object + if ($objectId === null) { + $objectId = UUIDUtil::getUUID(); + $this->backend->createCalendarObject( + $calendarId, + $objectId, + $objectData + ); + $outcome[$uid] = ['outcome' => 'created']; + } elseif ($objectId !== null) { + [$cid, $oid] = explode('/', $objectId); + if ($options->supersede) { + $this->backend->updateCalendarObject( + $calendarId, + $oid, + $objectData + ); + $outcome[$uid] = ['outcome' => 'updated']; + } else { + $outcome[$uid] = ['outcome' => 'exists']; + } + } + } + + return $outcome; + + } + + private function validateComponent(VCalendar $vObject, bool $repair, int $level): array { + // validate component(S) + $issues = $vObject->validate(Node::PROFILE_CALDAV); + // attempt to repair + if ($repair && count($issues) > 0) { + $issues = $vObject->validate(Node::REPAIR); + } + // filter out messages based on level + $result = []; + foreach ($issues as $key => $issue) { + if (isset($issue['level']) && $issue['level'] >= $level) { + $result[] = $issue['message']; + } + } + + return $result; + } + } diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php new file mode 100644 index 0000000000000..11fb57fa245a9 --- /dev/null +++ b/apps/dav/lib/CalDAV/Export/ExportService.php @@ -0,0 +1,117 @@ +exportStart($options->format); + + // iterate through each returned vCalendar entry + // extract each component except timezones, convert to appropriate format and output + // extract any timezones and save them but do not output + $timezones = []; + foreach ($calendar->export($options) as $entry) { + $consecutive = false; + foreach ($entry->getComponents() as $vComponent) { + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } else { + yield $this->exportObject($vComponent, $options->format, $consecutive); + $consecutive = true; + } + } + } + // iterate through each vTimezone entry, convert to appropriate format and output + foreach ($timezones as $vComponent) { + yield $this->exportObject($vComponent, $options->format, $consecutive); + $consecutive = true; + } + + yield $this->exportFinish($options->format); + + } + + /** + * Generates serialized content start based on selected format + * + * @since 32.0.0 + */ + private function exportStart(string $format): string { + return match ($format) { + 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar App\/\/EN"]],[', + 'xcal' => '2.0-//IDN nextcloud.com//Calendar App//EN', + default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar App//EN\n" + }; + } + + /** + * Generates serialized content end based on selected format + * + * @since 32.0.0 + */ + private function exportFinish(string $format): string { + return match ($format) { + 'jcal' => ']]', + 'xcal' => '', + default => "END:VCALENDAR\n" + }; + } + + /** + * Generates serialized content for a component based on selected format + * + * @since 32.0.0 + */ + private function exportObject(Component $vobject, string $format, bool $consecutive): string { + return match ($format) { + 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject), + 'xcal' => $this->exportObjectXml($vobject), + default => Writer::write($vobject) + }; + } + + /** + * Generates serialized content for a component in xml format + * + * @since 32.0.0 + */ + private function exportObjectXml(Component $vobject): string { + $writer = new \Sabre\Xml\Writer(); + $writer->openMemory(); + $writer->setIndent(false); + $vobject->xmlSerialize($writer); + return $writer->outputMemory(); + } + +} diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php new file mode 100644 index 0000000000000..734a51ef54e0d --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -0,0 +1,225 @@ +source = $source; + + switch ($options->format) { + case 'ical': + return $calendar->import($options, $this->importText(...)); + break; + case 'jcal': + return $calendar->import($options, $this->importJson(...)); + break; + case 'xcal': + return $calendar->import($options, $this->importXml(...)); + break; + default: + throw new \InvalidArgumentException('Invalid import format'); + } + + } + + /** + * Generates object stream from a text formatted source (ical) + * + * @since 32.0.0 + * + * @return Generator<\Sabre\VObject\Component\VCalendar> + */ + private function importText(CalendarImportOptions $options): Generator { + + $importer = new TextImporter($this->source); + + $structure = $importer->structure(); + + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + + // calendar properties + foreach ($structure['VCALENDAR'] as $entry) { + $sObjectPrefix .= $entry; + if (substr($entry, -1) !== "\n" || substr($entry, -2) !== "\r\n") { + $sObjectPrefix .= PHP_EOL; + } + } + + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $sObjectContents = $importer->extract($instance[2], $instance[3]); + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + + // calendar components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + // extract all instances of component and unserialize to object + $sObjectContents = ''; + foreach ($instances as $instance) { + $sObjectContents .= $importer->extract($instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + // add time zones to object + foreach ($this->findTimeZones($vObject) as $zone) { + if (isset($timezones[$zone])) { + $vObject->add(clone $timezones[$zone]); + } + } + // return object + yield $vObject; + + } + } + } + + /** + * Generates object stream from a xml formatted source (xcal) + * + * @since 32.0.0 + * + * @return Generator<\Sabre\VObject\Component\VCalendar> + */ + private function importXml(CalendarImportOptions $options): Generator { + + $importer = new XmlImporter($this->source); + + $structure = $importer->structure(); + + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $sObjectContents = $importer->extract($instance[2], $instance[3]); + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + + // calendar components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + // extract all instances of component and unserialize to object + $sObjectContents = ''; + foreach ($instances as $instance) { + $sObjectContents .= $importer->extract($instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + // add time zones to object + foreach ($this->findTimeZones($vObject) as $zone) { + if (isset($timezones[$zone])) { + $vObject->add(clone $timezones[$zone]); + } + } + // return object + yield $vObject; + + } + } + + } + + /** + * Generates object stream from a json formatted source (jcal) + * + * @since 32.0.0 + * + * @return Generator<\Sabre\VObject\Component\VCalendar> + */ + private function importJson(CalendarImportOptions $options): Generator { + + /** @var VCALENDAR $importer */ + $importer = Reader::readJson($this->source); + + // calendar time zones + $timezones = []; + foreach ($importer->VTIMEZONE as $timezone) { + $tzid = $timezone->TZID?->getValue(); + if ($tzid !== null) { + $timezones[$tzid] = clone $timezone; + } + } + + // calendar components + foreach ($importer->getBaseComponents() as $base) { + /** @var VCalendar $vObject */ + $vObject = new VCalendar; + $vObject->VERSION = clone $importer->VERSION; + $vObject->PRODID = clone $importer->PRODID; + // extract all instances of component + foreach ($importer->getByUID($base->UID->getValue()) as $instance) { + $vObject->add(clone $instance); + } + // add time zones to object + foreach ($this->findTimeZones($vObject) as $zone) { + if (isset($timezones[$zone])) { + $vObject->add(clone $timezones[$zone]); + } + } + // return object + yield $vObject; + + } + } + + /** + * Searches through all component properties looking for defined timezones + * + * @since 32.0.0 + * + * @return array + */ + private function findTimeZones(VCalendar $vObject): array { + $timezones = []; + foreach ($vObject->getComponents() as $vComponent) { + if ($vComponent->name !== 'VTIMEZONE') { + foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) { + if (isset($vComponent->$property?->parameters['TZID'])) { + $tid = $vComponent->$property->parameters['TZID']->getValue(); + $timezones[$tid] = true; + } + } + } + } + return array_keys($timezones); + } +} diff --git a/apps/dav/lib/CalDAV/Import/TextImporter.php b/apps/dav/lib/CalDAV/Import/TextImporter.php new file mode 100644 index 0000000000000..3cf92d8c80019 --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/TextImporter.php @@ -0,0 +1,123 @@ + [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + + public function __construct( + protected $source, + ) { + //Ensure that the $data var is of the right type + if (!is_string($source) && (!is_resource($source) || get_resource_type($source) !== 'stream')) { + throw new Exception('Source must be a string or a stream resource'); + } + } + + protected function analyze() { + + $componentStart = null; + $componentEnd = null; + $componentId = null; + $componentType = null; + + fseek($this->source, 0); + while (!feof($this->source)) { + $data = fgets($this->source); + + if ($data === false || empty(trim($data))) { + continue; + } + + if (ctype_space($data[0]) === false) { + + if (str_starts_with($data, 'BEGIN:')) { + $type = trim(substr($data, 6)); + if (in_array($type, $this->types)) { + $componentStart = ftell($this->source) - strlen($data); + $componentType = $type; + } + unset($type); + } + + if (str_starts_with($data, 'END:')) { + $type = trim(substr($data, 4)); + if ($componentType === $type) { + $componentEnd = ftell($this->source); + } + unset($type); + } + + if ($componentStart !== null && str_starts_with($data, 'UID:')) { + $componentId = trim(substr($data, 5)); + } + + if ($componentStart !== null && str_starts_with($data, 'TZID:')) { + $componentId = trim(substr($data, 5)); + } + + } + + if ($componentStart === null) { + if (!str_starts_with($data, 'BEGIN:VCALENDAR') && !str_starts_with($data, 'END:VCALENDAR')) { + $components['VCALENDAR'][] = $data; + } + } + + if ($componentEnd !== null) { + if ($componentId !== null) { + $this->structure[$componentType][$componentId][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } else { + $this->structure[$componentType][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } + $componentId = null; + $componentType = null; + $componentStart = null; + $componentEnd = null; + } + + } + + } + + public function structure(): array { + + if (!$this->analyzed) { + $this->analyze(); + } + + return $this->structure; + } + + public function extract(int $start, int $end): string { + + fseek($this->source, $start); + return fread($this->source, $end - $start); + + } + +} diff --git a/apps/dav/lib/CalDAV/Import/XmlImporter.php b/apps/dav/lib/CalDAV/Import/XmlImporter.php new file mode 100644 index 0000000000000..08d6bea6b1385 --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/XmlImporter.php @@ -0,0 +1,168 @@ +'; + public const OBJECT_SUFFIX = ''; + + protected array $types = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE']; + protected bool $analyzed = false; + protected array $structure = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + protected int $praseLevel = 0; + protected array $prasePath = []; + protected ?int $componentStart = null; + protected ?int $componentEnd = null; + protected int $componentLevel = 0; + protected ?string $componentId = null; + protected ?string $componentType = null; + protected bool $componentIdProperty = false; + + + public function __construct( + protected $source, + ) { + //Ensure that the $data var is of the right type + if (!is_string($source) && (!is_resource($source) || get_resource_type($source) !== 'stream')) { + throw new Exception('Source must be a string or a stream resource'); + } + } + + protected function analyze() { + + $this->praseLevel = 0; + $this->prasePath = []; + $this->componentStart = null; + $this->componentEnd = null; + $this->componentLevel = 0; + $this->componentId = null; + $this->componentType = null; + $this->componentIdProperty = false; + //Create the parser + $parser = xml_parser_create(); + // assign handlers + xml_set_object($parser, $this); + xml_set_element_handler($parser, $this->tagStart(...), $this->tagEnd(...)); + xml_set_default_handler($parser, $this->tagContents(...)); + //If the data is a resource then loop through it, otherwise just parse the string + if (is_resource($this->source)) { + //Not all resources support fseek. For those that don't, suppress the error + @fseek($this->source, 0); + while ($chunk = fread($this->source, 4096)) { + if (!xml_parse($parser, $chunk, feof($this->source))) { + throw new Exception( + xml_error_string(xml_get_error_code($parser)) + . ' At line: ' . + xml_get_current_line_number($parser) + ); + } + } + } else { + if (!xml_parse($parser, $this->source, true)) { + throw new Exception( + xml_error_string(xml_get_error_code($parser)) + . ' At line: ' . + xml_get_current_line_number($parser) + ); + } + } + + //Free up the parser + xml_parser_free($parser); + + } + + protected function tagStart($parser, $tag, $attributes) { + + $this->praseLevel++; + $this->prasePath[$this->praseLevel] = $tag; + + if (in_array($tag, $this->types)) { + $this->componentStart = xml_get_current_byte_index($parser) - (strlen($tag) + 1); + $this->componentType = $tag; + $this->componentLevel = $this->praseLevel; + } + + if ($this->componentStart !== null && + ($this->componentLevel + 2) === $this->praseLevel && + ($tag === 'UID' || $tag === 'TZID') + ) { + $this->componentIdProperty = true; + } + + return $parser; + } + + protected function tagEnd($parser, $tag) { + + if ($tag === 'UID' || $tag === 'TZID') { + $this->componentIdProperty = false; + } elseif ($this->componentType === $tag) { + $this->componentEnd = xml_get_current_byte_index($parser); + + if ($this->componentId !== null) { + $this->structure[$this->componentType][$this->componentId][] = [ + $this->componentType, + $this->componentId, + $this->componentStart, + $this->componentEnd, + implode('/', $this->prasePath) + ]; + } else { + $this->structure[$this->componentType][] = [ + $this->componentType, + $this->componentId, + $this->componentStart, + $this->componentEnd, + implode('/', $this->prasePath) + ]; + } + $this->componentStart = null; + $this->componentEnd = null; + $this->componentId = null; + $this->componentType = null; + $this->componentIdProperty = false; + } + + unset($this->prasePath[$this->praseLevel]); + $this->praseLevel--; + + return $parser; + } + + protected function tagContents($parser, $data) { + + if ($this->componentIdProperty) { + $this->componentId = $data; + } + + return $parser; + } + + + public function structure(): array { + + if (!$this->analyzed) { + $this->analyze(); + } + + return $this->structure; + } + + public function extract(int $start, int $end): string { + + fseek($this->source, $start); + return fread($this->source, $end - $start); + + } + +} diff --git a/apps/dav/lib/Command/ExportCalendar.php b/apps/dav/lib/Command/ExportCalendar.php new file mode 100644 index 0000000000000..abc2760c74658 --- /dev/null +++ b/apps/dav/lib/Command/ExportCalendar.php @@ -0,0 +1,96 @@ +setName('calendar:export') + ->setDescription('Export calendar data from supported calendars to disk or stdout') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('cid', InputArgument::REQUIRED, 'Id of calendar') + ->addArgument('format', InputArgument::OPTIONAL, 'Format of output (iCal, jCal, xCal) default to iCal') + ->addArgument('location', InputArgument::OPTIONAL, 'location of where to write the output. defaults to stdout'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('cid'); + $format = $input->getArgument('format'); + $location = $input->getArgument('location'); + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found."); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + throw new InvalidArgumentException("Calendar <$calendarId> dose support this function"); + } + // construct options object + $options = new CalendarExportOptions(); + // evaluate if provided format is supported + if ($format !== null && !in_array($format, $this->exportService::FORMATS)) { + throw new \InvalidArgumentException("Format <$format> is not valid."); + } else { + $options->format = $format ?? 'ical'; + } + // evaluate is a valid location was given and is usable otherwise output to stdout + if ($location !== null) { + $handle = fopen($location, 'w'); + if ($handle === false) { + throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation."); + } else { + foreach ($this->exportService->export($calendar, $options) as $chunk) { + fwrite($handle, $chunk); + } + fclose($handle); + } + } else { + foreach ($this->exportService->export($calendar, $options) as $chunk) { + $output->writeln($chunk); + } + } + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Command/ImportCalendar.php b/apps/dav/lib/Command/ImportCalendar.php new file mode 100644 index 0000000000000..7b508375bfa81 --- /dev/null +++ b/apps/dav/lib/Command/ImportCalendar.php @@ -0,0 +1,206 @@ +setName('calendar:import') + ->setDescription('Import calendar data to supported calendars from disk or stdin') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('cid', InputArgument::REQUIRED, 'Id of calendar') + ->addArgument('format', InputArgument::OPTIONAL, 'Format of output (iCal, jCal, xCal) default to iCal') + ->addArgument('location', InputArgument::OPTIONAL, 'location of where to write the output. defaults to stdin') + ->addOption('errors', null, InputOption::VALUE_REQUIRED, 'how to handel item errors (0 - continue, 1 - fail)') + ->addOption('validation', null, InputOption::VALUE_REQUIRED, 'how to handel item validation (0 - no validation, 1 - validate and skip on issue, 2 - validate and fail on issue)') + ->addOption('supersede', null, InputOption::VALUE_NONE, 'override/replace existing items') + ->addOption('show-created', null, InputOption::VALUE_NONE, 'show all created items after processing') + ->addOption('show-updated', null, InputOption::VALUE_NONE, 'show all updated items after processing') + ->addOption('show-skipped', null, InputOption::VALUE_NONE, 'show all skipped items after processing') + ->addOption('show-errors', null, InputOption::VALUE_NONE, 'show all errored items after processing') + ; + + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('cid'); + $format = $input->getArgument('format'); + $location = $input->getArgument('location'); + $errors = $input->getOption('errors'); + $validation = $input->getOption('validation'); + $supersede = $input->getOption('supersede') ? true : false; + $showCreated = $input->getOption('show-created') ? true : false; + $showUpdated = $input->getOption('show-updated') ? true : false; + $showSkipped = $input->getOption('show-skipped') ? true : false; + $showErrors = $input->getOption('show-errors') ? true : false; + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if import is supported and writeable + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found"); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarImport || !$calendar instanceof ICalendarIsWritable) { + throw new InvalidArgumentException("Calendar <$calendarId> dose support this function"); + } + if (!$calendar->isWritable()) { + throw new InvalidArgumentException("Calendar <$calendarId> is not writeable"); + } + if ($calendar->isDeleted()) { + throw new InvalidArgumentException("Calendar <$calendarId> is deleted"); + } + // construct options object + $options = new CalendarImportOptions(); + $options->supersede = $supersede; + if ($errors !== null) { + if ($errors < 0 || $errors > 1) { + throw new InvalidArgumentException('Invalid errors option specified'); + } + $options->errors = $errors; + } + if ($validation !== null) { + if ($validation < 0 || $validation > 2) { + throw new InvalidArgumentException('Invalid validation option specified'); + } + $options->validate = $validation; + } + // evaluate if provided format is supported + if ($format !== null && !in_array($format, $this->importService::FORMATS)) { + throw new \InvalidArgumentException("Format <$format> is not valid."); + } else { + $options->format = $format ?? 'ical'; + } + // evaluate if a valid location was given and is usable otherwise default to stdin + $timeStarted = microtime(true); + if ($location !== null) { + $input = fopen($location, 'r'); + if ($input === false) { + throw new \InvalidArgumentException("Location <$location> is not valid. Can not open location for read operation."); + } else { + try { + $outcome = $this->importService->import($input, $calendar, $options); + } finally { + fclose($input); + } + } + } else { + $input = fopen('php://stdin', 'r'); + if ($input === false) { + throw new \InvalidArgumentException('Can not open stdin for read operation.'); + } else { + try { + $temp = tmpfile(); + while (!feof($input)) { + fwrite($temp, fread($input, 8192)); + } + fseek($temp, 0); + $outcome = $this->importService->import($temp, $calendar, $options); + } finally { + fclose($input); + fclose($temp); + } + } + } + $timeFinished = microtime(true); + + $totalCreated = 0; + $totalUpdated = 0; + $totalSkipped = 0; + $totalErrors = 0; + + if ($outcome !== []) { + + if ($showCreated || $showUpdated || $showSkipped || $showErrors) { + $output->writeln(''); + } + + foreach ($outcome as $id => $result) { + if (isset($result['outcome'])) { + switch ($result['outcome']) { + case 'created': + $totalCreated++; + if ($showCreated) { + $output->writeln(['created: ' . $id]); + } + break; + case 'updated': + $totalUpdated++; + if ($showUpdated) { + $output->writeln(['updated: ' . $id]); + } + break; + case 'exists': + $totalSkipped++; + if ($showSkipped) { + $output->writeln(['skipped: ' . $id]); + } + break; + case 'error': + $totalErrors++; + if ($showErrors) { + $output->writeln(['errors: ' . $id]); + $output->writeln($result['errors']); + } + break; + } + } + + } + } + + $output->writeln([ + '', + 'Import Completed', + '================', + 'Execution Time: ' . (string)($timeFinished - $timeStarted) . ' sec', + 'Total Created: ' . (string)$totalCreated, + 'Total Updated: ' . (string)$totalUpdated, + 'Total Skipped: ' . (string)$totalSkipped, + 'Total Errors: ' . (string)$totalErrors, + '' + ]); + + return self::SUCCESS; + } +} diff --git a/apps/dav/lib/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php new file mode 100644 index 0000000000000..e840dfacb1c90 --- /dev/null +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -0,0 +1,91 @@ +userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($userId !== null) { + if (!$this->groupManager->isAdmin($this->userSession->getUser()->getUID()) && + $this->userSession->getUser()->getUID() !== $userId) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($userId)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new CalendarExportOptions(); + // evaluate if provided format is supported + if ($format !== null && !in_array($format, $this->exportService::FORMATS)) { + throw new \InvalidArgumentException("Format <$format> is not valid."); + } else { + $options->format = $format ?? 'ical'; + } + $contentType = match (strtolower($format)) { + 'jcal' => 'application/calendar+json; charset=UTF-8', + 'xcal' => 'application/calendar+xml; charset=UTF-8', + default => 'text/calendar; charset=UTF-8' + }; + + return new StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType); + + } +} diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php new file mode 100644 index 0000000000000..90f83db42140d --- /dev/null +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -0,0 +1,171 @@ +userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($userId !== null) { + if (!$this->groupManager->isAdmin($this->userSession->getUser()->getUID()) && + $this->userSession->getUser()->getUID() !== $userId) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($userId)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarImport) { + return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST); + } + // evaluate if requested format is supported and convert to output content type + if ($format !== null && !in_array($format, $this->importService::FORMATS)) { + return new DataResponse(['error' => 'format invalid'], Http::STATUS_BAD_REQUEST); + } elseif ($format === null) { + $format = 'ical'; + } + // retrieve calendar and evaluate if import is supported and writeable + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => "Calendar <$calendarId> not found"], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarImport || !$calendar instanceof ICalendarIsWritable) { + return new DataResponse(['error' => "Calendar <$calendarId> dose support this function"], Http::STATUS_BAD_REQUEST); + } + if (!$calendar->isWritable()) { + return new DataResponse(['error' => "Calendar <$calendarId> is not writeable"], Http::STATUS_BAD_REQUEST); + } + if ($calendar->isDeleted()) { + return new DataResponse(['error' => "Calendar <$calendarId> is deleted"], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new CalendarImportOptions(); + $options->supersede = $supersede; + if ($errors !== null) { + if ($errors < 0 || $errors > 1) { + return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST); + } + $options->errors = $errors; + } + if ($validation !== null) { + if ($validation < 0 || $validation > 2) { + return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST); + } + $options->validate = $validation; + } + // evaluate if provided format is supported + if ($format !== null && !in_array($format, $this->importService::FORMATS)) { + throw new \InvalidArgumentException("Format <$format> is not valid."); + } else { + $options->format = $format ?? 'ical'; + } + // + $timeStarted = microtime(true); + $input = fopen('php://stdin', 'r'); + try { + $temp = tmpfile(); + fwrite($temp, $data); + fseek($temp, 0); + $outcome = $this->importService->import($temp, $calendar, $options); + } finally { + fclose($input); + fclose($temp); + } + $timeFinished = microtime(true); + + $totalCreated = 0; + $totalUpdated = 0; + $totalSkipped = 0; + $totalErrors = 0; + + if ($outcome !== []) { + foreach ($outcome as $id => $result) { + if (isset($result['outcome'])) { + switch ($result['outcome']) { + case 'created': + $totalCreated++; + break; + case 'updated': + $totalUpdated++; + break; + case 'exists': + $totalSkipped++; + break; + case 'error': + $totalErrors++; + break; + } + } + + } + } + + $summary = [ + 'time' => ($timeFinished - $timeStarted), + 'created' => $totalCreated, + 'updated' => $totalUpdated, + 'skipped' => $totalSkipped, + 'errors' => $totalErrors, + ]; + + return new DataResponse($summary, Http::STATUS_OK); + + } +} diff --git a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php index ee9b85fafe8b5..144f935bb03f9 100644 --- a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php +++ b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php @@ -5,6 +5,7 @@ */ namespace OCA\DAV\Tests\unit\CalDAV; +use Generator; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\Calendar; @@ -12,32 +13,31 @@ use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; use OCA\DAV\CalDAV\Schedule\Plugin; use OCA\DAV\Connector\Sabre\Server; +use OCP\Calendar\CalendarImportOptions; use OCP\Calendar\Exceptions\CalendarException; use PHPUnit\Framework\MockObject\MockObject; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; use Sabre\VObject\ITip\Message; use Sabre\VObject\Reader; +use Sabre\VObject\UUIDUtil; class CalendarImplTest extends \Test\TestCase { - /** @var CalendarImpl */ - private $calendarImpl; - /** @var Calendar | \PHPUnit\Framework\MockObject\MockObject */ - private $calendar; - - /** @var array */ - private $calendarInfo; - - /** @var CalDavBackend | \PHPUnit\Framework\MockObject\MockObject */ - private $backend; + private Calendar|MockObject $calendar; + private array $calendarInfo; + private CalDavBackend|MockObject $backend; + private CalendarImpl|MockObject $calendarImpl; + private UUIDUtil|MockObject $uuidUtil; + private array $mockImportCollection; + private array $mockExportCollection; protected function setUp(): void { parent::setUp(); $this->calendar = $this->createMock(Calendar::class); $this->calendarInfo = [ - 'id' => 'fancy_id_123', + 'id' => 1, '{DAV:}displayname' => 'user readable name 123', '{http://apple.com/ns/ical/}calendar-color' => '#AABBCC', 'uri' => '/this/is/a/uri', @@ -45,13 +45,18 @@ protected function setUp(): void { ]; $this->backend = $this->createMock(CalDavBackend::class); - $this->calendarImpl = new CalendarImpl($this->calendar, - $this->calendarInfo, $this->backend); + $this->calendarImpl = new CalendarImpl( + $this->calendar, + $this->calendarInfo, + $this->backend + ); + + $this->uuidUtil = $this->createMock(UUIDUtil::class); } public function testGetKey(): void { - $this->assertEquals($this->calendarImpl->getKey(), 'fancy_id_123'); + $this->assertEquals($this->calendarImpl->getKey(), 1); } public function testGetDisplayname(): void { @@ -261,4 +266,239 @@ private function getITipMessage($calendarData): Message { $iTipMessage->message = $vObject; return $iTipMessage; } + + protected function mockImportGenerator(CalendarImportOptions $options): Generator { + foreach ($this->mockImportCollection as $entry) { + yield $entry; + } + } + + protected function mockExportGenerator(): Generator { + foreach ($this->mockExportCollection as $entry) { + yield $entry; + } + } + + public function testExport(): void { + // construct calendar with a 1 hour event and same start/end time zones + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Recurrence Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + // construct data store return + $this->mockExportCollection[] = [ + 'id' => 1, + 'calendardata' => $vCalendar->serialize() + ]; + $this->backend->expects($this->once()) + ->method('exportCalendar') + ->with(1, $this->backend::CALENDAR_TYPE_CALENDAR, null) + ->willReturn($this->mockExportGenerator()); + // test export + foreach ($this->calendarImpl->export(null) as $entry) { + $exported[] = $entry; + } + $this->assertCount(1, $exported, 'Invalid exported items count'); + } + + public function testImportNewObject(): void { + + // construct calendar with a 1 hour event and same start/end time zones + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Recurrence Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $this->mockImportCollection[] = $vCalendar; + // construct mock backend + $this->backend->expects($this->once()) + ->method('getCalendarObjectByUID') + ->with( + $this->calendarInfo['principaluri'], + $vEvent->UID->getValue() + ) + ->willReturn(null); + $this->backend->expects($this->once()) + ->method('createCalendarObject') + ->withAnyParameters(); + + $options = new CalendarImportOptions(); + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('96a0e6b1-d886-4a55-a60d-152b31401dcc', $outcome, 'No import status returned for object'); + $this->assertEquals('created', $outcome['96a0e6b1-d886-4a55-a60d-152b31401dcc']['outcome'], 'Invalid import status returned for object'); + + } + + public function testImportExistingObjectUpdated(): void { + + // construct calendar with a 1 hour event and same start/end time zones + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Recurrence Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $this->mockImportCollection[] = $vCalendar; + // construct mock backend + $this->backend->expects($this->once()) + ->method('getCalendarObjectByUID') + ->with( + $this->calendarInfo['principaluri'], + $vEvent->UID->getValue() + ) + ->willReturn($this->calendarInfo['id'] . '/' . $vEvent->UID->getValue()); + $this->backend->expects($this->once()) + ->method('updateCalendarObject') + ->withAnyParameters(); + + $options = new CalendarImportOptions(); + $options->supersede = true; + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('96a0e6b1-d886-4a55-a60d-152b31401dcc', $outcome, 'No import status returned for object'); + $this->assertEquals('updated', $outcome['96a0e6b1-d886-4a55-a60d-152b31401dcc']['outcome'], 'Invalid import status returned for object'); + + } + + public function testImportExistingObjectExists(): void { + + // construct calendar with a 1 hour event and same start/end time zones + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Recurrence Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + $this->mockImportCollection[] = $vCalendar; + // construct mock backend + $this->backend->expects($this->once()) + ->method('getCalendarObjectByUID') + ->with( + $this->calendarInfo['principaluri'], + $vEvent->UID->getValue() + ) + ->willReturn($this->calendarInfo['id'] . '/' . $vEvent->UID->getValue()); + + $options = new CalendarImportOptions(); + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('96a0e6b1-d886-4a55-a60d-152b31401dcc', $outcome, 'No import status returned for object'); + $this->assertEquals('exists', $outcome['96a0e6b1-d886-4a55-a60d-152b31401dcc']['outcome'], 'Invalid import status returned for object'); + + } + + public function testImportErrorNoBaseObject(): void { + + // construct calendar object + $vCalendar = new VCalendar(); + $this->mockImportCollection[] = $vCalendar; + + $options = new CalendarImportOptions(); + $options->errors = 0; + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('nbct', $outcome, 'No import status returned for object'); + $this->assertEquals('error', $outcome['nbct']['outcome'], 'Invalid import status returned for object'); + + } + + public function testImportErrorMultipleBaseObjects(): void { + + // construct calendar object + $vCalendar = new VCalendar(); + $vCalendar->add('VEVENT', []); + $vCalendar->add('VTODO', []); + $this->mockImportCollection[] = $vCalendar; + + $options = new CalendarImportOptions(); + $options->errors = 0; + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('mbct', $outcome, 'No import status returned for object'); + $this->assertEquals('error', $outcome['mbct']['outcome'], 'Invalid import status returned for object'); + + } + + public function testImportErrorNoUid(): void { + + // construct calendar object + $vCalendar = new VCalendar(); + $vCalendar->add('VEVENT', []); + $vCalendar->VEVENT->remove('UID'); + $this->mockImportCollection[] = $vCalendar; + + $options = new CalendarImportOptions(); + $options->errors = 0; + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('noid', $outcome, 'No import status returned for object'); + $this->assertEquals('error', $outcome['noid']['outcome'], 'Invalid import status returned for object'); + + } + + public function testImportErrorValidation(): void { + + // construct calendar object + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $this->mockImportCollection[] = $vCalendar; + + $options = new CalendarImportOptions(); + $options->errors = 0; + // test import + $outcome = $this->calendarImpl->import($options, $this->mockImportGenerator(...)); + $this->assertCount(1, $outcome, 'No import status returned'); + $this->assertArrayHasKey('96a0e6b1-d886-4a55-a60d-152b31401dcc', $outcome, 'No import status returned for object'); + $this->assertEquals('error', $outcome['96a0e6b1-d886-4a55-a60d-152b31401dcc']['outcome'], 'Invalid import status returned for object'); + + } + } diff --git a/apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php b/apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php new file mode 100644 index 0000000000000..aab423610b9ef --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php @@ -0,0 +1,72 @@ +service = new ExportService(); + $this->calendar = $this->createMock(ICalendarExport::class); + + } + + protected function mockGenerator(): Generator { + foreach ($this->mockExportCollection as $entry) { + yield $entry; + } + } + + public function testExport(): void { + + // construct calendar with a 1 hour event and same start/end time zones + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Recurrence Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + // construct calendar return + $options = new CalendarExportOptions(); + $this->mockExportCollection[] = $vCalendar; + $this->calendar->expects($this->once()) + ->method('export') + ->with($options) + ->willReturn($this->mockGenerator()); + // test export + $document = ''; + foreach ($this->service->export($this->calendar, $options) as $chunk) { + $document .= $chunk; + } + $this->assertTrue(str_contains($document, 'BEGIN:VCALENDAR'), 'Exported document calendar start missing'); + $this->assertTrue(str_contains($document, 'BEGIN:VEVENT'), 'Exported document event start missing'); + $this->assertTrue(str_contains($document, 'END:VEVENT'), 'Exported document event end missing'); + $this->assertTrue(str_contains($document, 'END:VCALENDAR'), 'Exported document calendar end missing'); + + } + +} diff --git a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php new file mode 100644 index 0000000000000..405ddc81e0db3 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -0,0 +1,70 @@ +service = new ImportService(); + $this->calendar = $this->createMock(ICalendarImport::class); + + } + + public function mockCollector(CalendarImportOptions $options, callable $generator): array { + foreach ($generator($options) as $entry) { + $this->mockImportCollection[] = $entry; + } + return []; + } + + public function testImport(): void { + + // construct calendar with a 1 hour event and same start/end time zones + $vCalendar = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $vCalendar->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Recurrence Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); + // construct stream from mock calendar + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $vCalendar->serialize()); + rewind($stream); + // construct import options + $options = new CalendarImportOptions(); + $this->calendar->expects($this->once()) + ->method('import') + ->willReturnCallback($this->mockCollector(...)); + // test import + $this->service->import($stream, $this->calendar, $options); + $this->assertCount(1, $this->mockImportCollection, 'Imported items count is invalid'); + $this->assertTrue(isset($this->mockImportCollection[0]->VEVENT), 'Imported item missing VEVENT'); + $this->assertCount(1, $this->mockImportCollection[0]->VEVENT, 'Imported items count is invalid'); + + } + +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index cbadc2deb15b4..0caa74b43fd4b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -110,6 +110,7 @@ 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => $baseDir . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => $baseDir . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', + 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StrictContentSecurityPolicy' => $baseDir . '/lib/public/AppFramework/Http/StrictContentSecurityPolicy.php', 'OCP\\AppFramework\\Http\\StrictEvalContentSecurityPolicy' => $baseDir . '/lib/public/AppFramework/Http/StrictEvalContentSecurityPolicy.php', @@ -190,10 +191,14 @@ 'OCP\\Broadcast\\Events\\IBroadcastEvent' => $baseDir . '/lib/public/Broadcast/Events/IBroadcastEvent.php', 'OCP\\Cache\\CappedMemoryCache' => $baseDir . '/lib/public/Cache/CappedMemoryCache.php', 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => $baseDir . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', + 'OCP\\Calendar\\CalendarExportOptions' => $baseDir . '/lib/public/Calendar/CalendarExportOptions.php', + 'OCP\\Calendar\\CalendarImportOptions' => $baseDir . '/lib/public/Calendar/CalendarImportOptions.php', 'OCP\\Calendar\\Exceptions\\CalendarException' => $baseDir . '/lib/public/Calendar/Exceptions/CalendarException.php', 'OCP\\Calendar\\IAvailabilityResult' => $baseDir . '/lib/public/Calendar/IAvailabilityResult.php', 'OCP\\Calendar\\ICalendar' => $baseDir . '/lib/public/Calendar/ICalendar.php', 'OCP\\Calendar\\ICalendarEventBuilder' => $baseDir . '/lib/public/Calendar/ICalendarEventBuilder.php', + 'OCP\\Calendar\\ICalendarExport' => $baseDir . '/lib/public/Calendar/ICalendarExport.php', + 'OCP\\Calendar\\ICalendarImport' => $baseDir . '/lib/public/Calendar/ICalendarImport.php', 'OCP\\Calendar\\ICalendarIsShared' => $baseDir . '/lib/public/Calendar/ICalendarIsShared.php', 'OCP\\Calendar\\ICalendarIsWritable' => $baseDir . '/lib/public/Calendar/ICalendarIsWritable.php', 'OCP\\Calendar\\ICalendarProvider' => $baseDir . '/lib/public/Calendar/ICalendarProvider.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 10086cabce066..7d0bef4bfa318 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -159,6 +159,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', + 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StrictContentSecurityPolicy' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StrictContentSecurityPolicy.php', 'OCP\\AppFramework\\Http\\StrictEvalContentSecurityPolicy' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StrictEvalContentSecurityPolicy.php', @@ -239,10 +240,14 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Broadcast\\Events\\IBroadcastEvent' => __DIR__ . '/../../..' . '/lib/public/Broadcast/Events/IBroadcastEvent.php', 'OCP\\Cache\\CappedMemoryCache' => __DIR__ . '/../../..' . '/lib/public/Cache/CappedMemoryCache.php', 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', + 'OCP\\Calendar\\CalendarExportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarExportOptions.php', + 'OCP\\Calendar\\CalendarImportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarImportOptions.php', 'OCP\\Calendar\\Exceptions\\CalendarException' => __DIR__ . '/../../..' . '/lib/public/Calendar/Exceptions/CalendarException.php', 'OCP\\Calendar\\IAvailabilityResult' => __DIR__ . '/../../..' . '/lib/public/Calendar/IAvailabilityResult.php', 'OCP\\Calendar\\ICalendar' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendar.php', 'OCP\\Calendar\\ICalendarEventBuilder' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarEventBuilder.php', + 'OCP\\Calendar\\ICalendarExport' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarExport.php', + 'OCP\\Calendar\\ICalendarImport' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarImport.php', 'OCP\\Calendar\\ICalendarIsShared' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsShared.php', 'OCP\\Calendar\\ICalendarIsWritable' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsWritable.php', 'OCP\\Calendar\\ICalendarProvider' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarProvider.php', diff --git a/lib/public/AppFramework/Http/StreamGeneratorResponse.php b/lib/public/AppFramework/Http/StreamGeneratorResponse.php new file mode 100644 index 0000000000000..9c12acb9d8c5d --- /dev/null +++ b/lib/public/AppFramework/Http/StreamGeneratorResponse.php @@ -0,0 +1,58 @@ +> + */ +class StreamGeneratorResponse extends Response implements ICallbackResponse { + protected $generator; + + /** + * @since 32.0.0 + * + * @param \Generator $generator the function to call to generate the response + * @param String $contentType http response content type e.g. 'application/json; charset=UTF-8' + * @param int $status http response status + */ + public function __construct(Generator $generator, string $contentType, int $status = 200) { + parent::__construct(); + + $this->generator = $generator; + + $this->setStatus($status); + $this->cacheFor(0); + $this->addHeader('Content-Type', $contentType); + + } + + /** + * Streams content directly to client + * + * @since 32.0.0 + * + * @param IOutput $output a small wrapper that handles output + */ + public function callback(IOutput $output) { + + foreach ($this->generator as $chunk) { + print($chunk); + flush(); + } + + } + +} diff --git a/lib/public/Calendar/CalendarExportOptions.php b/lib/public/Calendar/CalendarExportOptions.php new file mode 100644 index 0000000000000..51e55b5faaa5a --- /dev/null +++ b/lib/public/Calendar/CalendarExportOptions.php @@ -0,0 +1,22 @@ +