diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index baf1021d3e60c..1d14f8e15fa01 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -66,6 +66,7 @@ OCA\DAV\Command\ExportCalendar OCA\DAV\Command\FixCalendarSyncCommand OCA\DAV\Command\GetAbsenceCommand + 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 9eab04561595d..9028b7c73099d 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -69,6 +69,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', @@ -166,6 +169,7 @@ 'OCA\\DAV\\Command\\ExportCalendar' => $baseDir . '/../lib/Command/ExportCalendar.php', 'OCA\\DAV\\Command\\FixCalendarSyncCommand' => $baseDir . '/../lib/Command/FixCalendarSyncCommand.php', 'OCA\\DAV\\Command\\GetAbsenceCommand' => $baseDir . '/../lib/Command/GetAbsenceCommand.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 e9a0ef01c0778..6b6fbaef093d6 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -84,6 +84,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', @@ -181,6 +184,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Command\\ExportCalendar' => __DIR__ . '/..' . '/../lib/Command/ExportCalendar.php', 'OCA\\DAV\\Command\\FixCalendarSyncCommand' => __DIR__ . '/..' . '/../lib/Command/FixCalendarSyncCommand.php', 'OCA\\DAV\\Command\\GetAbsenceCommand' => __DIR__ . '/..' . '/../lib/Command/GetAbsenceCommand.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/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 17d83081ea361..6aea1ba436f0f 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -2424,7 +2424,7 @@ public function searchPrincipalUri(string $principalUri, * @param string $uid * @return string|null */ - public function getCalendarObjectByUID($principalUri, $uid) { + public function getCalendarObjectByUID($principalUri, $uid, $calendarUri = null) { $query = $this->db->getQueryBuilder(); $query->selectAlias('c.uri', 'calendaruri')->selectAlias('co.uri', 'objecturi') ->from('calendarobjects', 'co') @@ -2432,6 +2432,11 @@ public function getCalendarObjectByUID($principalUri, $uid) { ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid))) ->andWhere($query->expr()->isNull('co.deleted_at')); + + if ($calendarUri !== null) { + $query->andWhere($query->expr()->eq('c.uri', $query->createNamedParameter($calendarUri))); + } + $stmt = $query->executeQuery(); $row = $stmt->fetch(); $stmt->closeCursor(); diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index 5f912da732eb8..ef3e26b534568 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -28,6 +28,7 @@ use Sabre\VObject\ITip\Message; use Sabre\VObject\Property; use Sabre\VObject\Reader; + use function Sabre\Uri\split as uriSplit; class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled { @@ -54,6 +55,14 @@ public function getUri(): string { return $this->calendarInfo['uri']; } + /** + * @return string the principal URI of the calendar owner + * @since 32.0.0 + */ + public function getPrincipalUri(): string { + return $this->calendarInfo['principaluri']; + } + /** * In comparison to getKey() this function returns a human readable (maybe translated) name * @since 13.0.0 diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php new file mode 100644 index 0000000000000..a3126b2091376 --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -0,0 +1,334 @@ +>> + * + * @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'); + } + } + + /** + * Generates object stream from a text formatted source (ical) + * + * @return Generator<\Sabre\VObject\Component\VCalendar> + */ + private function importText(): Generator { + $importer = new TextImporter($this->source); + $structure = $importer->structure(); + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + // calendar properties + foreach ($structure['VCALENDAR'] as $entry) { + if (!str_ends_with($entry, "\n") || !str_ends_with($entry, "\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(): 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(): 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); + } + + /** + * Import objects + * + * @since 32.0.0 + * + * @param CalendarImportOptions $options + * @param callable $generator: Generator<\Sabre\VObject\Component\VCalendar> + * + * @return array>> + */ + public function importProcess(CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { + $calendarId = $calendar->getKey(); + $calendarUri = $calendar->getUri(); + $principalUri = $calendar->getPrincipalUri(); + $outcome = []; + foreach ($generator() 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 = $components[0]->UID->getValue(); + // validate object + if ($options->getValidate() !== $options::VALIDATE_NONE) { + $issues = $this->componentValidate($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($principalUri, $uid, $calendarUri); + $objectData = $vObject->serialize(); + try { + if ($objectId === null) { + $objectId = UUIDUtil::getUUID(); + $this->backend->createCalendarObject( + $calendarId, + $objectId, + $objectData + ); + $outcome[$uid] = ['outcome' => 'created']; + } else { + [$cid, $oid] = explode('/', $objectId); + if ($options->getSupersede()) { + $this->backend->updateCalendarObject( + $calendarId, + $oid, + $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; + } + + /** + * 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 + */ + private function componentValidate(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/Import/TextImporter.php b/apps/dav/lib/CalDAV/Import/TextImporter.php new file mode 100644 index 0000000000000..7871c783bb8ce --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/TextImporter.php @@ -0,0 +1,156 @@ + [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + + /** + * @param resource $source + */ + public function __construct( + private $source, + ) { + // Ensure that source is a stream resource + if (!is_resource($source) || get_resource_type($source) !== 'stream') { + throw new Exception('Source must be a stream resource'); + } + } + + /** + * Analyzes the source data and creates a structure of components + */ + private 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; + } + // lines with whitespace at the beginning are continuations of the previous 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..b37e65170ebfd --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/XmlImporter.php @@ -0,0 +1,173 @@ +'; + public const OBJECT_SUFFIX = ''; + private const COMPONENT_TYPES = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE']; + + private bool $analyzed = false; + private array $structure = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + private int $praseLevel = 0; + private array $prasePath = []; + private ?int $componentStart = null; + private ?int $componentEnd = null; + private int $componentLevel = 0; + private ?string $componentId = null; + private ?string $componentType = null; + private bool $componentIdProperty = false; + + /** + * @param resource $source + */ + public function __construct( + private $source, + ) { + // Ensure that source is a stream resource + if (!is_resource($source) || get_resource_type($source) !== 'stream') { + throw new Exception('Source must be a stream resource'); + } + } + + /** + * Analyzes the source data and creates a structure of components + */ + private 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(...)); + // 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) + ); + } + } + //Free up the parser + xml_parser_free($parser); + } + + /** + * Handles start of tag events from the parser for all tags + */ + private 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 + */ + private 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 + */ + private 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..4c25c6b2605f0 --- /dev/null +++ b/apps/dav/lib/Command/ImportCalendar.php @@ -0,0 +1,189 @@ +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') + ->addArgument('location', InputArgument::OPTIONAL, 'Location to read the input from, defaults to stdin.') + ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of input (ical, jcal, xcal) defaults to ical', 'ical') + ->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'); + $location = $input->getArgument('location'); + $format = $input->getOption('format'); + $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 CalendarImpl) { + throw new InvalidArgumentException("Calendar <$calendarId> doesn't 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) { + $options->setErrors($errors); + } + if ($validation !== null) { + $options->setValidate($validation); + } + $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: ' . ($timeFinished - $timeStarted) . ' sec', + 'Total Created: ' . $totalCreated, + 'Total Updated: ' . $totalUpdated, + 'Total Skipped: ' . $totalSkipped, + 'Total Errors: ' . $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..0cfafae22bcdd --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -0,0 +1,150 @@ +backend = $this->createMock(CalDavBackend::class); + $this->service = new ImportService($this->backend); + $this->calendar = $this->createMock(CalendarImpl::class); + + } + + 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(); + $options->setFormat('ical'); + + // Mock calendar methods + $this->calendar->expects($this->once()) + ->method('getKey') + ->willReturn('calendar-id-123'); + $this->calendar->expects($this->once()) + ->method('getPrincipalUri') + ->willReturn('principals/users/test-user'); + + // Mock backend methods + $this->backend->expects($this->once()) + ->method('getCalendarObjectByUID') + ->with('principals/users/test-user', '96a0e6b1-d886-4a55-a60d-152b31401dcc') + ->willReturn(null); // Object doesn't exist, so it will be created + + $this->backend->expects($this->once()) + ->method('createCalendarObject') + ->with( + 'calendar-id-123', + $this->isType('string'), // Object ID (UUID) + $this->isType('string') // Object data + ); + + // Act + $result = $this->service->import($stream, $this->calendar, $options); + + // Assert + $this->assertIsArray($result); + $this->assertCount(1, $result, 'Import result should contain one item'); + $this->assertArrayHasKey('96a0e6b1-d886-4a55-a60d-152b31401dcc', $result); + $this->assertEquals('created', $result['96a0e6b1-d886-4a55-a60d-152b31401dcc']['outcome']); + } + + 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(); + $options->setFormat('ical'); + + $longUID = '040000008200E00074C5B7101A82E00800000000000000000000000000000000000000004D0000004D14C68E6D285940B19A7D3D1DC1F8D23230323130363137743133333234387A2D383733323234373636303740666538303A303A303A303A33643A623066663A666533643A65383830656E7335'; + + // Mock calendar methods + $this->calendar->expects($this->once()) + ->method('getKey') + ->willReturn('calendar-id-123'); + $this->calendar->expects($this->once()) + ->method('getPrincipalUri') + ->willReturn('principals/users/test-user'); + + // Mock backend methods + $this->backend->expects($this->once()) + ->method('getCalendarObjectByUID') + ->with('principals/users/test-user', $longUID) + ->willReturn(null); // Object doesn't exist, so it will be created + + $this->backend->expects($this->once()) + ->method('createCalendarObject') + ->with( + 'calendar-id-123', + $this->isType('string'), // Object ID (UUID) + $this->isType('string') // Object data + ); + + // Act + $result = $this->service->import($stream, $this->calendar, $options); + + // Assert + $this->assertIsArray($result); + $this->assertCount(1, $result, 'Import result should contain one item'); + $this->assertArrayHasKey($longUID, $result); + $this->assertEquals('created', $result[$longUID]['outcome']); + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index ca44c540714e2..f0839175ede09 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -204,6 +204,7 @@ 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => $baseDir . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\CalendarEventStatus' => $baseDir . '/lib/public/Calendar/CalendarEventStatus.php', 'OCP\\Calendar\\CalendarExportOptions' => $baseDir . '/lib/public/Calendar/CalendarExportOptions.php', + 'OCP\\Calendar\\CalendarImportOptions' => $baseDir . '/lib/public/Calendar/CalendarImportOptions.php', 'OCP\\Calendar\\Events\\AbstractCalendarObjectEvent' => $baseDir . '/lib/public/Calendar/Events/AbstractCalendarObjectEvent.php', 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent' => $baseDir . '/lib/public/Calendar/Events/CalendarObjectCreatedEvent.php', 'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent' => $baseDir . '/lib/public/Calendar/Events/CalendarObjectDeletedEvent.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 7f5f597a33447..c137e5539c5af 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -245,6 +245,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\CalendarEventStatus' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarEventStatus.php', 'OCP\\Calendar\\CalendarExportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarExportOptions.php', + 'OCP\\Calendar\\CalendarImportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarImportOptions.php', 'OCP\\Calendar\\Events\\AbstractCalendarObjectEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/AbstractCalendarObjectEvent.php', 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/CalendarObjectCreatedEvent.php', 'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/CalendarObjectDeletedEvent.php', diff --git a/lib/public/Calendar/CalendarImportOptions.php b/lib/public/Calendar/CalendarImportOptions.php new file mode 100644 index 0000000000000..2bdddfd50b37a --- /dev/null +++ b/lib/public/Calendar/CalendarImportOptions.php @@ -0,0 +1,123 @@ +format; + } + + /** + * Sets the import format + * + * @param 'ical'|'jcal'|'xcal' $value + */ + public function setFormat(string $value): void { + if (!in_array($value, self::FORMATS, true)) { + throw new InvalidArgumentException('Format is not valid.'); + } + $this->format = $value; + } + + /** + * 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 $value 0 - continue, 1 - fail + * + * @template $value of self::ERROR_* + */ + public function setErrors(int $value): void { + if (!in_array($value, CalendarImportOptions::ERROR_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid errors option specified'); + } + $this->errors = $value; + } + + /** + * 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 $value 0 - no validation, 1 - validate and skip on issue, 2 - validate and fail on issue + * + * @template $value of self::VALIDATE_* + */ + public function setValidate(int $value): void { + if (!in_array($value, CalendarImportOptions::VALIDATE_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid validation option specified'); + } + $this->validate = $value; + } + +}