diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index a4ce51fd89cff..a6c085836583b 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -65,6 +65,7 @@ OCA\DAV\Command\DeleteSubscription OCA\DAV\Command\ExportCalendar OCA\DAV\Command\FixCalendarSyncCommand + OCA\DAV\Command\ImportCalendar OCA\DAV\Command\ListAddressbooks OCA\DAV\Command\ListCalendarShares OCA\DAV\Command\ListCalendars diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 4c48f343c4c3b..c917a1a5cc7b8 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -68,6 +68,9 @@ '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', @@ -164,6 +167,7 @@ 'OCA\\DAV\\Command\\DeleteSubscription' => $baseDir . '/../lib/Command/DeleteSubscription.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\\ListCalendarShares' => $baseDir . '/../lib/Command/ListCalendarShares.php', 'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 4d9166a2d5a89..aee01ff0014dc 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -83,6 +83,9 @@ class ComposerStaticInitDAV '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', @@ -179,6 +182,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Command\\DeleteSubscription' => __DIR__ . '/..' . '/../lib/Command/DeleteSubscription.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\\ListCalendarShares' => __DIR__ . '/..' . '/../lib/Command/ListCalendarShares.php', 'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php', diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index b79bf7ea2d073..0507effbe347c 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -8,13 +8,17 @@ */ namespace OCA\DAV\CalDAV; +use Exception; 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\ICalendarIsEnabled; +use OCP\Calendar\ICalendarImport; use OCP\Calendar\ICalendarIsShared; use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; @@ -26,8 +30,11 @@ 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, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled { @@ -199,6 +206,22 @@ public function createFromString(string $name, string $calendarData): void { } } + public function createCalendarObject(string $name, string $calendarData): string { + return $this->backend->createCalendarObject( + $this->calendarInfo['id'], + $name, + $calendarData + ); + } + + public function updateCalendarObject(string $name, string $calendarData): string { + return $this->backend->updateCalendarObject( + $this->calendarInfo['id'], + $name, + $calendarData + ); + } + /** * @throws CalendarException */ @@ -261,6 +284,32 @@ public function getInvitationResponseServer(): InvitationResponseServer { return new InvitationResponseServer(false); } + /** + * Validate a component + * + * @param VCalendar $vObject + * @param bool $repair attempt to repair the component + * @param int $level minimum level of issues to return + * @return list + */ + public 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; + } + /** * Export objects * diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php new file mode 100644 index 0000000000000..334fe666af50b --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -0,0 +1,322 @@ +>> + * + * @throws \InvalidArgumentException + */ + public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): array { + if (!is_resource($source)) { + throw new \InvalidArgumentException('Invalid import source must be a file resource'); + } + + $this->source = $source; + + switch ($options->getFormat()) { + case 'ical': + return $this->importProcess($calendar, $options, $this->importText(...)); + break; + case 'jcal': + return $this->importProcess($calendar, $options, $this->importJson(...)); + break; + case 'xcal': + return $this->importProcess($calendar, $options, $this->importXml(...)); + break; + default: + throw new \InvalidArgumentException('Invalid import format'); + } + } + + private function importProcess(CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { + $calendarId = $calendar->getKey(); + $outcome = []; + foreach ($generator($options) as $vObject) { + $components = $vObject->getBaseComponents(); + // determine if the object has no base component types + if (count($components) === 0) { + $errorMessage = 'One or more objects discovered with no base component types'; + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); + } + $outcome['nbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + continue; + } + // determine if the object has more than one base component type + // object can have multiple base components with the same uid + // but we need to make sure they are of the same type + if (count($components) > 1) { + $type = $components[0]->name; + foreach ($components as $entry) { + if ($type !== $entry->name) { + $errorMessage = 'One or more objects discovered with multiple base component types'; + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); + } + $outcome['mbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + continue 2; + } + } + } + // determine if the object has a uid + if (!isset($components[0]->UID)) { + $errorMessage = 'One or more objects discovered without a UID'; + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); + } + $outcome['noid'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + continue; + } + $uid = (string)$components[0]->UID->getValue(); + // validate object + if ($options->getValidate() !== $options::VALIDATE_NONE) { + $issues = $calendar->validateComponent($vObject, true, 3); + if ($options->getValidate() === $options::VALIDATE_SKIP && $issues !== []) { + $outcome[$uid] = ['outcome' => 'error', 'errors' => $issues]; + continue; + } elseif ($options->getValidate() === $options::VALIDATE_FAIL && $issues !== []) { + throw new InvalidArgumentException('Error importing calendar data: UID <' . $uid . '> - ' . $issues[0]); + } + } + // create or update object in the data store + //$objectId = $this->backend->getCalendarObjectByUID($this->calendarInfo['principaluri'], $uid); + $objects = $calendar->search( + '', + [], + ['uid' => $uid], + 1 + ); + if (count($objects) > 0) { + $objectId = $objects[0]['uri']; + } else { + $objectId = null; + } + $objectData = $vObject->serialize(); + try { + if ($objectId === null) { + $objectId = UUIDUtil::getUUID() . '.ics'; + //$this->backend->createCalendarObject( + // $calendarId, + // $objectId, + // $objectData + //); + + // This is not the best option as it spins up the full dav server and generates iTip/iMip messages + //$calendar->createFromString($objectId, $vObject->serialize()); + + // Create the calendar object in the calendar + $calendar->createCalendarObject( + $objectId, + $objectData + ); + + $outcome[$uid] = ['outcome' => 'created']; + } elseif ($objectId !== null) { + //[$cid, $oid] = explode('/', $objectId); + if ($options->getSupersede()) { + //$this->backend->updateCalendarObject( + // $calendarId, + // $oid, + // $objectData + //); + + // Update the calendar object in the calendar + $calendar->updateCalendarObject( + $objectId, + $objectData + ); + + $outcome[$uid] = ['outcome' => 'updated']; + } else { + $outcome[$uid] = ['outcome' => 'exists']; + } + } + } catch (Exception $e) { + $errorMessage = $e->getMessage(); + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e); + } + $outcome[$uid] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + } + } + + return $outcome; + } + + /** + * Generates object stream from a text formatted source (ical) + * + * @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((int)$instance[2], (int)$instance[3]); + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + // calendar components + // for each component type, construct a full calendar object with all components + // that match the same UID and appropriate time zones that are used in the components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + /** @var array $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]); + } + } + yield $vObject; + } + } + } + + /** + * Generates object stream from a xml formatted source (xcal) + * + * @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((int)$instance[2], (int)$instance[3]); + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + // calendar components + // for each component type, construct a full calendar object with all components + // that match the same UID and appropriate time zones that are used in the components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + /** @var array $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]); + } + } + yield $vObject; + } + } + } + + /** + * Generates object stream from a json formatted source (jcal) + * + * @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) { + $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]); + } + } + yield $vObject; + } + } + + /** + * Searches through all component properties looking for defined timezones + * + * @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..e7aac81145a50 --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/TextImporter.php @@ -0,0 +1,154 @@ + [], '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'); + } + } + + /** + * Analyzes the source data and creates a structure of components + */ + protected function analyze() { + $componentStart = null; + $componentEnd = null; + $componentId = null; + $componentType = null; + $tagName = null; + $tagValue = null; + + // iterate through the source data line by line + fseek($this->source, 0); + while (!feof($this->source)) { + $data = fgets($this->source); + // skip empty lines + if ($data === false || empty(trim($data))) { + continue; + } + // check for withspace at the beginning of the line + // lines with whitespace at the beginning are continuations of the pervious line + if (ctype_space($data[0]) === false) { + // detect the line TAG + // detect the first occurrence of ':' or ';' + $colonPos = strpos($data, ':'); + $semicolonPos = strpos($data, ';'); + if ($colonPos !== false && $semicolonPos !== false) { + $splitPosition = min($colonPos, $semicolonPos); + } elseif ($colonPos !== false) { + $splitPosition = $colonPos; + } elseif ($semicolonPos !== false) { + $splitPosition = $semicolonPos; + } else { + continue; + } + $tagName = strtoupper(trim(substr($data, 0, $splitPosition))); + $tagValue = trim(substr($data, $splitPosition + 1)); + $tagContinuation = false; + } else { + $tagContinuation = true; + $tagValue .= trim($data); + } + + if ($tagContinuation === false) { + // check line for component start, remember the position and determine the type + if ($tagName === 'BEGIN' && in_array($tagValue, self::COMPONENT_TYPES, true)) { + $componentStart = ftell($this->source) - strlen($data); + $componentType = $tagValue; + } + // check line for component end, remember the position + if ($tagName === 'END' && $componentType === $tagValue) { + $componentEnd = ftell($this->source); + } + // check line for component id + if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) { + $componentId = $tagValue; + } + } else { + // check line for component id + if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) { + $componentId = $tagValue; + } + } + // any line(s) not inside a component are VCALENDAR properties + if ($componentStart === null) { + if ($tagName !== 'BEGIN' && $tagName !== 'END' && $tagValue === 'VCALENDAR') { + $components['VCALENDAR'][] = $data; + } + } + // if component start and end are found, add the component to the structure + if ($componentStart !== null && $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; + } + } + } + + /** + * Returns the analyzed structure of the source data + * the analyzed structure is a collection of components organized by type, + * each entry is a collection of instances + * [ + * 'VEVENT' => [ + * '7456f141-b478-4cb9-8efc-1427ba0d6839' => [ + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 0, 100 ], + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 100, 200 ] + * ] + * ] + * ] + */ + public function structure(): array { + if (!$this->analyzed) { + $this->analyze(); + } + return $this->structure; + } + + /** + * Extracts a string chuck from the source data + * + * @param int $start starting byte position + * @param int $end ending byte position + */ + 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..73c26a122bec8 --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/XmlImporter.php @@ -0,0 +1,181 @@ +'; + public const OBJECT_SUFFIX = ''; + protected const COMPONENT_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 source 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'); + } + } + + /** + * Analyzes the source data and creates a structure of components + */ + 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 and assign tag handlers + $parser = xml_parser_create(); + xml_set_object($parser, $this); + xml_set_element_handler($parser, $this->tagStart(...), $this->tagEnd(...)); + xml_set_default_handler($parser, $this->tagContents(...)); + // If the source is resource then prase if in chunks, otherwise just parse the full source + if (is_resource($this->source)) { + // iterate through the source data chuck by chunk to trigger the handlers + @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); + } + + /** + * Handles start of tag events from the parser for all tags + */ + protected function tagStart(XMLParser $parser, string $tag, array $attributes): void { + // add the tag to the path tracker and increment depth the level + $this->praseLevel++; + $this->prasePath[$this->praseLevel] = $tag; + // determine if the tag is a component type and remember the byte position + if (in_array($tag, self::COMPONENT_TYPES, true)) { + $this->componentStart = xml_get_current_byte_index($parser) - (strlen($tag) + 1); + $this->componentType = $tag; + $this->componentLevel = $this->praseLevel; + } + // determine if the tag is a sub tag of the component and an id property + if ($this->componentStart !== null && + ($this->componentLevel + 2) === $this->praseLevel && + ($tag === 'UID' || $tag === 'TZID') + ) { + $this->componentIdProperty = true; + } + } + + /** + * Handles end of tag events from the parser for all tags + */ + protected function tagEnd(XMLParser $parser, string $tag): void { + // if the end tag matched the component type or the component id property + // then add the component to the structure + 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; + } + // remove the tag from the path tacker and depth the level + unset($this->prasePath[$this->praseLevel]); + $this->praseLevel--; + } + + /** + * Handles tag contents events from the parser for all tags + */ + protected function tagContents(XMLParser $parser, string $data): void { + if ($this->componentIdProperty) { + $this->componentId = $data; + } + } + + /** + * Returns the analyzed structure of the source data + * the analyzed structure is a collection of components organized by type, + * each entry is a collection of instances + * [ + * 'VEVENT' => [ + * '7456f141-b478-4cb9-8efc-1427ba0d6839' => [ + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 0, 100 ], + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 100, 200 ] + * ] + * ] + * ] + */ + public function structure(): array { + if (!$this->analyzed) { + $this->analyze(); + } + return $this->structure; + } + + /** + * Extracts a string chuck from the source data + * + * @param int $start starting byte position + * @param int $end ending byte position + */ + 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/ImportCalendar.php b/apps/dav/lib/Command/ImportCalendar.php new file mode 100644 index 0000000000000..c72768751854f --- /dev/null +++ b/apps/dav/lib/Command/ImportCalendar.php @@ -0,0 +1,200 @@ +setName('calendar:import') + ->setDescription('Import calendar data to supported calendars from disk or stdin') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('uri', InputArgument::REQUIRED, 'Uri of calendar') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of input (ical, jcal, xcal) defaults to ical', 'ical') + ->addOption('location', null, InputOption::VALUE_REQUIRED, 'Location of where to write the input. 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('uri'); + $format = $input->getOption('format'); + $location = $input->getOption('location'); + $errors = is_numeric($input->getOption('errors')) ? (int)$input->getOption('errors') : null; + $validation = is_numeric($input->getOption('validation')) ? (int)$input->getOption('validation') : null; + $supersede = $input->getOption('supersede'); + $showCreated = $input->getOption('show-created'); + $showUpdated = $input->getOption('show-updated'); + $showSkipped = $input->getOption('show-skipped'); + $showErrors = $input->getOption('show-errors'); + + 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->setSupersede($supersede); + if ($errors !== null) { + if (!in_array($errors, CalendarImportOptions::ERROR_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid errors option specified'); + } + $options->setErrors($errors); + } + if ($validation !== null) { + if (!in_array($validation, CalendarImportOptions::VALIDATE_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid validation option specified'); + } + $options->setValidate($validation); + } + // evaluate if provided format is supported + if (!in_array($format, ImportService::FORMATS, true)) { + throw new InvalidArgumentException("Format <$format> is not valid."); + } + $options->setFormat($format); + // 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."); + } + 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.'); + } + try { + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + while (!feof($input)) { + fwrite($tempFile, fread($input, 8192)); + } + fseek($tempFile, 0); + $outcome = $this->importService->import($tempFile, $calendar, $options); + } finally { + fclose($input); + fclose($tempFile); + } + } + $timeFinished = microtime(true); + + // summarize the outcome + $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/tests/unit/CalDAV/Import/ImportServiceTest.php b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php new file mode 100644 index 0000000000000..b7889cdd563d7 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -0,0 +1,108 @@ +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 { + // Arrange + // 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(...)); + + // Act + $this->service->import($stream, $this->calendar, $options); + + // Assert + $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'); + } + + public function testImportWithMultiLineUID(): void { + // Arrange + // 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('040000008200E00074C5B7101A82E00800000000000000000000000000000000000000004D0000004D14C68E6D285940B19A7D3D1DC1F8D23230323130363137743133333234387A2D383733323234373636303740666538303A303A303A303A33643A623066663A666533643A65383830656E7335'); + $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(...)); + + // Act + $this->service->import($stream, $this->calendar, $options); + + // Assert + $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 c97549e395f51..85f9445d5a95b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -192,6 +192,7 @@ 'OCP\\Cache\\CappedMemoryCache' => $baseDir . '/lib/public/Cache/CappedMemoryCache.php', 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => $baseDir . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\CalendarEventStatus' => $baseDir . '/lib/public/Calendar/CalendarEventStatus.php', + 'OCP\\Calendar\\CalendarImportOptions' => $baseDir . '/lib/public/Calendar/CalendarImportOptions.php', 'OCP\\Calendar\\CalendarExportOptions' => $baseDir . '/lib/public/Calendar/CalendarExportOptions.php', 'OCP\\Calendar\\Events\\AbstractCalendarObjectEvent' => $baseDir . '/lib/public/Calendar/Events/AbstractCalendarObjectEvent.php', 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent' => $baseDir . '/lib/public/Calendar/Events/CalendarObjectCreatedEvent.php', @@ -204,6 +205,7 @@ '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\\ICalendarImport' => $baseDir . '/lib/public/Calendar/ICalendarImport.php', 'OCP\\Calendar\\ICalendarExport' => $baseDir . '/lib/public/Calendar/ICalendarExport.php', 'OCP\\Calendar\\ICalendarIsEnabled' => $baseDir . '/lib/public/Calendar/ICalendarIsEnabled.php', 'OCP\\Calendar\\ICalendarIsShared' => $baseDir . '/lib/public/Calendar/ICalendarIsShared.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 39880f3366fe8..f5c74fcb1c1e6 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -233,6 +233,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Cache\\CappedMemoryCache' => __DIR__ . '/../../..' . '/lib/public/Cache/CappedMemoryCache.php', 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\CalendarEventStatus' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarEventStatus.php', + 'OCP\\Calendar\\CalendarImportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarImportOptions.php', 'OCP\\Calendar\\CalendarExportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarExportOptions.php', 'OCP\\Calendar\\Events\\AbstractCalendarObjectEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/AbstractCalendarObjectEvent.php', 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/CalendarObjectCreatedEvent.php', @@ -245,6 +246,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 '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\\ICalendarImport' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarImport.php', 'OCP\\Calendar\\ICalendarExport' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarExport.php', 'OCP\\Calendar\\ICalendarIsEnabled' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsEnabled.php', 'OCP\\Calendar\\ICalendarIsShared' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsShared.php', diff --git a/lib/public/Calendar/CalendarImportOptions.php b/lib/public/Calendar/CalendarImportOptions.php new file mode 100644 index 0000000000000..6b05074cae07d --- /dev/null +++ b/lib/public/Calendar/CalendarImportOptions.php @@ -0,0 +1,116 @@ +format; + } + + /** + * Sets the import format + * + * @param 'ical'|'jcal'|'xcal' $format + */ + public function setFormat(string $format): void { + $this->format = $format; + } + + /** + * Gets whether to supersede existing objects + */ + public function getSupersede(): bool { + return $this->supersede; + } + + /** + * Sets whether to supersede existing objects + */ + public function setSupersede(bool $supersede): void { + $this->supersede = $supersede; + } + + /** + * Gets how to handle object errors + * + * @return int 0 - continue, 1 - fail + */ + public function getErrors(): int { + return $this->errors; + } + + /** + * Sets how to handle object errors + * + * @param int $errors 0 - continue, 1 - fail + * + * @template $errors of self::ERROR_* + */ + public function setErrors(int $errors): void { + if (!in_array($errors, self::ERROR_OPTIONS, true)) { + throw new \InvalidArgumentException("Invalid errors handling type <$errors> specified, only ERROR_CONTINUE or ERROR_FAIL allowed"); + } + $this->errors = $errors; + } + + /** + * Gets how to handle object validation + * + * @return int 0 - no validation, 1 - validate and skip on issue, 2 - validate and fail on issue + */ + public function getValidate(): int { + return $this->validate; + } + + /** + * Sets how to handle object validation + * + * @param int $validate 0 - no validation, 1 - validate and skip on issue, 2 - validate and fail on issue + * + * @template $validate of self::VALIDATE_* + */ + public function setValidate(int $validate): void { + if (!in_array($validate, self::VALIDATE_OPTIONS, true)) { + throw new \InvalidArgumentException("Invalid validation handling type <$validate> specified, only VALIDATE_NONE, VALIDATE_SKIP or VALIDATE_FAIL allowed"); + } + $this->validate = $validate; + } + +}