>> * * @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'); } switch ($options->getFormat()) { case 'ical': return $this->importProcess($source, $calendar, $options, $this->importText(...)); break; case 'jcal': return $this->importProcess($source, $calendar, $options, $this->importJson(...)); break; case 'xcal': return $this->importProcess($source, $calendar, $options, $this->importXml(...)); break; default: throw new InvalidArgumentException('Invalid import format'); } } /** * Generates object stream from a text formatted source (ical) * * @param resource $source * * @return Generator<\Sabre\VObject\Component\VCalendar> */ public function importText($source): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } $importer = new TextImporter($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) * * @param resource $source * * @return Generator<\Sabre\VObject\Component\VCalendar> */ public function importXml($source): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } $importer = new XmlImporter($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) * * @param resource $source * * @return Generator<\Sabre\VObject\Component\VCalendar> */ public function importJson($source): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } /** @var VCALENDAR $importer */ $importer = Reader::readJson($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 resource $source * @param CalendarImportOptions $options * @param callable $generator: Generator<\Sabre\VObject\Component\VCalendar> * * @return array>> */ public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { $calendarId = $calendar->getKey(); $calendarUri = $calendar->getUri(); $principalUri = $calendar->getPrincipalUri(); $outcome = []; foreach ($generator($source) 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; } }