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;
+ }
+
+}