From 9e175b9fe7704ab725aadb10dfc975b33c450739 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Sat, 13 Jun 2026 13:45:23 -0400 Subject: [PATCH] feat: contacts import ocs Signed-off-by: SebastianKrupinski --- .../composer/composer/autoload_classmap.php | 7 + .../dav/composer/composer/autoload_static.php | 7 + apps/dav/lib/CardDAV/AddressBookImpl.php | 23 +- .../lib/CardDAV/Import/ImportCountEvent.php | 33 ++ .../lib/CardDAV/Import/ImportDisposition.php | 16 + apps/dav/lib/CardDAV/Import/ImportEvent.php | 14 + .../lib/CardDAV/Import/ImportObjectEvent.php | 40 ++ apps/dav/lib/CardDAV/Import/ImportService.php | 408 ++++++++++++++++++ apps/dav/lib/CardDAV/Import/TextImporter.php | 157 +++++++ .../Controller/ContactsImportController.php | 146 +++++++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 13 +- lib/private/ContactsManager.php | 15 + lib/public/Contacts/ContactsImportOptions.php | 177 ++++++++ lib/public/Contacts/IManager.php | 9 + 15 files changed, 1059 insertions(+), 7 deletions(-) create mode 100644 apps/dav/lib/CardDAV/Import/ImportCountEvent.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportDisposition.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportEvent.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportObjectEvent.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportService.php create mode 100644 apps/dav/lib/CardDAV/Import/TextImporter.php create mode 100644 apps/dav/lib/Controller/ContactsImportController.php create mode 100644 lib/public/Contacts/ContactsImportOptions.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 91e4b33551ad5..debec694f00c1 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -172,6 +172,12 @@ 'OCA\\DAV\\CardDAV\\Converter' => $baseDir . '/../lib/CardDAV/Converter.php', 'OCA\\DAV\\CardDAV\\HasPhotoPlugin' => $baseDir . '/../lib/CardDAV/HasPhotoPlugin.php', 'OCA\\DAV\\CardDAV\\ImageExportPlugin' => $baseDir . '/../lib/CardDAV/ImageExportPlugin.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportCountEvent' => $baseDir . '/../lib/CardDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportDisposition' => $baseDir . '/../lib/CardDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportEvent' => $baseDir . '/../lib/CardDAV/Import/ImportEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportObjectEvent' => $baseDir . '/../lib/CardDAV/Import/ImportObjectEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportService' => $baseDir . '/../lib/CardDAV/Import/ImportService.php', + 'OCA\\DAV\\CardDAV\\Import\\TextImporter' => $baseDir . '/../lib/CardDAV/Import/TextImporter.php', 'OCA\\DAV\\CardDAV\\Integration\\ExternalAddressBook' => $baseDir . '/../lib/CardDAV/Integration/ExternalAddressBook.php', 'OCA\\DAV\\CardDAV\\Integration\\IAddressBookProvider' => $baseDir . '/../lib/CardDAV/Integration/IAddressBookProvider.php', 'OCA\\DAV\\CardDAV\\MultiGetExportPlugin' => $baseDir . '/../lib/CardDAV/MultiGetExportPlugin.php', @@ -267,6 +273,7 @@ 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\CalendarExportController' => $baseDir . '/../lib/Controller/CalendarExportController.php', 'OCA\\DAV\\Controller\\CalendarImportController' => $baseDir . '/../lib/Controller/CalendarImportController.php', + 'OCA\\DAV\\Controller\\ContactsImportController' => $baseDir . '/../lib/Controller/ContactsImportController.php', 'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 587f073e9d541..e0b6e8d0296fa 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -187,6 +187,12 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CardDAV\\Converter' => __DIR__ . '/..' . '/../lib/CardDAV/Converter.php', 'OCA\\DAV\\CardDAV\\HasPhotoPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/HasPhotoPlugin.php', 'OCA\\DAV\\CardDAV\\ImageExportPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/ImageExportPlugin.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportCountEvent' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportDisposition' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportEvent' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportObjectEvent' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportObjectEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportService.php', + 'OCA\\DAV\\CardDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CardDAV/Import/TextImporter.php', 'OCA\\DAV\\CardDAV\\Integration\\ExternalAddressBook' => __DIR__ . '/..' . '/../lib/CardDAV/Integration/ExternalAddressBook.php', 'OCA\\DAV\\CardDAV\\Integration\\IAddressBookProvider' => __DIR__ . '/..' . '/../lib/CardDAV/Integration/IAddressBookProvider.php', 'OCA\\DAV\\CardDAV\\MultiGetExportPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/MultiGetExportPlugin.php', @@ -282,6 +288,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\CalendarExportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarExportController.php', 'OCA\\DAV\\Controller\\CalendarImportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarImportController.php', + 'OCA\\DAV\\Controller\\ContactsImportController' => __DIR__ . '/..' . '/../lib/Controller/ContactsImportController.php', 'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php', diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index 2413b2c9cc34f..684e8d48bc36b 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -11,6 +11,7 @@ use OCA\DAV\Db\PropertyMapper; use OCP\Constants; use OCP\IAddressBookEnabled; +use OCP\IAddressBookWritable; use OCP\ICreateContactFromString; use OCP\IURLGenerator; use Sabre\VObject\Component\VCard; @@ -18,7 +19,7 @@ use Sabre\VObject\Reader; use Sabre\VObject\UUIDUtil; -class AddressBookImpl implements IAddressBookEnabled, ICreateContactFromString { +class AddressBookImpl implements ICreateContactFromString, IAddressBookEnabled, IAddressBookWritable { /** * AddressBookImpl constructor. @@ -57,6 +58,14 @@ public function getUri(): string { return $this->addressBookInfo['uri']; } + /** + * @return string the principal URI of the address book owner + * @since 35.0.0 + */ + public function getPrincipalUri(): string { + return $this->addressBookInfo['principaluri']; + } + /** * In comparison to getKey() this function returns a human readable (maybe translated) name * @@ -350,6 +359,18 @@ public function isEnabled(): bool { return true; } + public function isWritable(): bool { + if (!$this->userId) { + return true; + } + + if ($this->isSystemAddressBook()) { + return false; + } + + return $this->getPermissions() & Constants::PERMISSION_UPDATE; + } + #[\Override] public function createFromString(string $name, string $vcfData): void { $this->backend->createCard($this->getKey(), $name, $vcfData); diff --git a/apps/dav/lib/CardDAV/Import/ImportCountEvent.php b/apps/dav/lib/CardDAV/Import/ImportCountEvent.php new file mode 100644 index 0000000000000..3aa89ec50795f --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportCountEvent.php @@ -0,0 +1,33 @@ +vevent + $this->vtodo + $this->vjournal; + } + + /** + * @return array{type: 'count', vcard: int} + */ + #[\Override] + public function jsonSerialize(): array { + return [ + 'type' => 'count', + 'vcard' => $this->total(), + ]; + } +} diff --git a/apps/dav/lib/CardDAV/Import/ImportDisposition.php b/apps/dav/lib/CardDAV/Import/ImportDisposition.php new file mode 100644 index 0000000000000..a7400da402b3f --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportDisposition.php @@ -0,0 +1,16 @@ + $errors + */ + public function __construct( + public ?string $identifier, + public ImportDisposition $disposition, + public array $errors = [], + ) { + } + + public function isError(): bool { + return $this->disposition === ImportDisposition::Error; + } + + /** + * @return array{type: 'object', identifier: ?string, disposition: string, errors: list} + */ + #[\Override] + public function jsonSerialize(): array { + $result = [ + 'type' => 'object', + 'identifier' => $this->identifier, + 'disposition' => $this->disposition->value, + 'errors' => $this->errors, + ]; + + return $result; + } +} diff --git a/apps/dav/lib/CardDAV/Import/ImportService.php b/apps/dav/lib/CardDAV/Import/ImportService.php new file mode 100644 index 0000000000000..e2f87d67604cf --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportService.php @@ -0,0 +1,408 @@ + + * + * @throws \InvalidArgumentException + */ + public function import($source, AddressBookImpl $addressBook, ContactsImportOptions $options): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + return match ($options->getFormat()) { + 'ical' => $this->importProcess($source, $addressBook, $options, $this->importText(...)), + 'jcal' => $this->importProcess($source, $addressBook, $options, $this->importJson(...)), + 'xcal' => $this->importProcess($source, $addressBook, $options, $this->importXml(...)), + default => throw new InvalidArgumentException('Invalid import format'), + }; + } + + /** + * Generates object stream from a text formatted source (ical) + * + * @param resource $source + * + * @return Generator + */ + public function importText($source, ?ContactsImportOptions $options = null): 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; + } + // object counts before streaming if requested + if ($options?->getCounts()) { + yield 'counts' => [ + 'VCARD' => count($structure['VCARD']) + ]; + } + // 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 + */ + public function importXml($source, ?ContactsImportOptions $options = null): 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; + } + // object counts before streaming if requested + if ($options?->getCounts()) { + yield 'counts' => [ + 'VEVENT' => count($structure['VEVENT']), + 'VTODO' => count($structure['VTODO']), + 'VJOURNAL' => count($structure['VJOURNAL']), + ]; + } + // 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 + * @param ContactsImportOptions|null $options + * + * @return Generator + */ + public function importJson($source, ?ContactsImportOptions $options = null): 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 + $baseComponents = $importer->getBaseComponents(); + // object counts before streaming if requested + if ($options?->getCounts()) { + /** @var array{VEVENT: int, VTODO: int, VJOURNAL: int} $counts */ + $counts = ['VEVENT' => 0, 'VTODO' => 0, 'VJOURNAL' => 0]; + foreach ($baseComponents as $component) { + switch ($component->name) { + case 'VEVENT': + $counts['VEVENT']++; + break; + case 'VTODO': + $counts['VTODO']++; + break; + case 'VJOURNAL': + $counts['VJOURNAL']++; + break; + } + } + yield 'counts' => $counts; + } + foreach ($baseComponents 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 Generator + */ + public function importProcess($source, AddressBookImpl $addressBook, ContactsImportOptions $options, callable $generator): Generator { + $addressBookId = $addressBook->getKey(); + $addressBookUri = $addressBook->getUri(); + $principalUri = $addressBook->getPrincipalUri(); + foreach ($generator($source, $options) as $key => $value) { + if ($key === 'counts') { + yield new ImportCountEvent( + vevent: $value['VEVENT'] ?? 0, + vtodo: $value['VTODO'] ?? 0, + vjournal: $value['VJOURNAL'] ?? 0, + ); + continue; + } + $vObject = $value; + $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); + } + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + 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); + } + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + 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); + } + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + 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 !== []) { + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + 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->getAddressBookObjectByUID($principalUri, $uid, $addressBookUri); + $objectData = $vObject->serialize(); + try { + if ($objectId === null) { + $objectId = UUIDUtil::getUUID(); + $this->backend->createAddressBookObject( + $addressBookId, + $objectId, + $objectData + ); + yield new ImportObjectEvent( + disposition: ImportDisposition::Created, + identifier: $uid, + ); + } else { + [$cid, $oid] = explode('/', $objectId); + if ($options->getSupersede()) { + $this->backend->updateAddressBookObject( + $addressBookId, + $oid, + $objectData + ); + yield new ImportObjectEvent( + disposition: ImportDisposition::Updated, + identifier: $uid, + ); + } else { + yield new ImportObjectEvent( + disposition: ImportDisposition::Exists, + identifier: $uid, + ); + } + } + } catch (Exception $e) { + $errorMessage = $e->getMessage(); + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e); + } + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + errors: [$errorMessage] + ); + } + } + } + + /** + * 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/CardDAV/Import/TextImporter.php b/apps/dav/lib/CardDAV/Import/TextImporter.php new file mode 100644 index 0000000000000..97f9eea236681 --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/TextImporter.php @@ -0,0 +1,157 @@ + [], '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/Controller/ContactsImportController.php b/apps/dav/lib/Controller/ContactsImportController.php new file mode 100644 index 0000000000000..c636ab5202195 --- /dev/null +++ b/apps/dav/lib/Controller/ContactsImportController.php @@ -0,0 +1,146 @@ +, total: non-negative-int} + */ +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CardDAV\Import\ImportService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\OCSController; +use OCP\Contacts\ContactsImportOptions; +use OCP\Contacts\IManager; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\ITempManager; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\IAddressBookWritable; + +class ContactsImportController extends OCSController { + + public function __construct( + IRequest $request, + private IUserSession $userSession, + private IUserManager $userManager, + private IGroupManager $groupManager, + private ITempManager $tempManager, + private IManager $contactsManager, + private ImportService $importService, + ) { + parent::__construct(Application::APP_ID, $request); + } + + /** + * Import contacts data + * + * @param string $transaction client generated transaction id + * @param string $target address book id + * @param array{format?:string, validation?:0|1|2, errors?:0|1, supersede?:bool} $options configuration options + * @param string $data contacts data + * @param string|null $user system user id + * + * @return StreamGeneratorResponse | DataResponse + * + * 200: NDJSON stream of import event objects + * 400: invalid parameters + * 401: user not authorized + */ + #[ApiRoute(verb: 'POST', url: '/import', root: '/contacts')] + #[UserRateLimit(limit: 10, period: 3600)] + #[NoAdminRequired] + public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { + $addressBookId = $target; + $format = isset($options['format']) ? $options['format'] : null; + $validation = isset($options['validation']) ? (int)$options['validation'] : null; + $errors = isset($options['errors']) ? (int)$options['errors'] : null; + $supersede = $options['supersede'] ?? false; + // evaluate if user is logged in and has permissions + if (!$this->userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($user !== null) { + if ($this->userSession->getUser()->getUID() !== $user + && $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($user)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + $userId = $user; + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve address book and evaluate if import is supported and writeable + $addressBooks = $this->contactsManager->getAddressBooksForPrincipal('principals/users/' . $userId, [$addressBookId]); + if ($addressBooks === []) { + return new DataResponse(['error' => "Address book <$addressBookId> not found"], Http::STATUS_BAD_REQUEST); + } + $addressBook = $addressBooks[0]; + if (!$addressBook instanceof IAddressBookWritable) { + return new DataResponse(['error' => "Address book <$addressBookId> does not support this function"], Http::STATUS_BAD_REQUEST); + } + if (!$addressBook->isWritable()) { + return new DataResponse(['error' => "Address book <$addressBookId> is not writeable"], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new ContactsImportOptions(); + $options->setSupersede($supersede); + if ($errors !== null) { + try { + $options->setErrors($errors); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST); + } + } + if ($validation !== null) { + try { + $options->setValidate($validation); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST); + } + } + try { + $options->setFormat($format ?? 'ical'); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST); + } + $options->setCounts(true); + // process the data + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + fwrite($tempFile, $data); + unset($data); + fseek($tempFile, 0); + + $importGenerator = $this->importService->import($tempFile, $addressBook, $options); + $stream = (function () use ($importGenerator, $tempFile, $transaction): \Generator { + yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'start']) . "\n"; + try { + foreach ($importGenerator as $result) { + $data = $result->jsonSerialize(); + $data['transaction'] = $transaction; + yield json_encode($data) . PHP_EOL; + } + } finally { + yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'end']) . "\n"; + fclose($tempFile); + } + })(); + + return new StreamGeneratorResponse($stream, 'application/x-ndjson'); + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 655025ba0bca4..504496664a7c6 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -306,6 +306,7 @@ 'OCP\\Console\\ConsoleEvent' => $baseDir . '/lib/public/Console/ConsoleEvent.php', 'OCP\\Console\\ReservedOptions' => $baseDir . '/lib/public/Console/ReservedOptions.php', 'OCP\\Constants' => $baseDir . '/lib/public/Constants.php', + 'OCP\\Contacts\\ContactsImportOptions' => $baseDir . '/lib/public/Contacts/ContactsImportOptions.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => $baseDir . '/lib/public/Contacts/ContactsMenu/IAction.php', 'OCP\\Contacts\\ContactsMenu\\IActionFactory' => $baseDir . '/lib/public/Contacts/ContactsMenu/IActionFactory.php', 'OCP\\Contacts\\ContactsMenu\\IBulkProvider' => $baseDir . '/lib/public/Contacts/ContactsMenu/IBulkProvider.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 9a3b924f50a79..841e1937e6f03 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -11,32 +11,32 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 ); public static $prefixLengthsPsr4 = array ( - 'O' => + 'O' => array ( 'OC\\Core\\' => 8, 'OC\\' => 3, 'OCP\\' => 4, ), - 'N' => + 'N' => array ( 'NCU\\' => 4, ), ); public static $prefixDirsPsr4 = array ( - 'OC\\Core\\' => + 'OC\\Core\\' => array ( 0 => __DIR__ . '/../../..' . '/core', ), - 'OC\\' => + 'OC\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/private', ), - 'OCP\\' => + 'OCP\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/public', ), - 'NCU\\' => + 'NCU\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/unstable', ), @@ -347,6 +347,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Console\\ConsoleEvent' => __DIR__ . '/../../..' . '/lib/public/Console/ConsoleEvent.php', 'OCP\\Console\\ReservedOptions' => __DIR__ . '/../../..' . '/lib/public/Console/ReservedOptions.php', 'OCP\\Constants' => __DIR__ . '/../../..' . '/lib/public/Constants.php', + 'OCP\\Contacts\\ContactsImportOptions' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsImportOptions.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IAction.php', 'OCP\\Contacts\\ContactsMenu\\IActionFactory' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IActionFactory.php', 'OCP\\Contacts\\ContactsMenu\\IBulkProvider' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IBulkProvider.php', diff --git a/lib/private/ContactsManager.php b/lib/private/ContactsManager.php index 847dd535fa9b7..f698b0d531aa8 100644 --- a/lib/private/ContactsManager.php +++ b/lib/private/ContactsManager.php @@ -154,6 +154,21 @@ public function getUserAddressBooks(): array { return $this->addressBooks; } + public function getAddressBooksForPrincipal(string $principalUri, array $addressBookUris = []): array { + $this->loadAddressBooks(); + $result = []; + foreach ($this->addressBooks as $addressBook) { + if (!method_exists($addressBook, 'getPrincipalUri')) { + continue; + } + + if ($addressBook->getPrincipalUri() === $principalUri && in_array($addressBook->getUri(), $addressBookUris, true)) { + $result[] = $addressBook; + } + } + return $result; + } + /** * removes all registered address book instances */ diff --git a/lib/public/Contacts/ContactsImportOptions.php b/lib/public/Contacts/ContactsImportOptions.php new file mode 100644 index 0000000000000..2f212b5cbbe3b --- /dev/null +++ b/lib/public/Contacts/ContactsImportOptions.php @@ -0,0 +1,177 @@ +format; + } + + /** + * Sets the import format + * + * @param 'ical'|'jcal'|'xcal' $value + * @since 35.0.0 + */ + 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 + * + * @since 35.0.0 + */ + public function getSupersede(): bool { + return $this->supersede; + } + + /** + * Sets whether to supersede existing objects + * + * @since 35.0.0 + */ + public function setSupersede(bool $supersede): void { + $this->supersede = $supersede; + } + + /** + * Gets how to handle object errors + * + * @return int 0 - continue, 1 - fail + * @since 35.0.0 + */ + 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_* + * @since 35.0.0 + */ + public function setErrors(int $value): void { + if (!in_array($value, self::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 + * @since 35.0.0 + */ + 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_* + * @since 35.0.0 + */ + public function setValidate(int $value): void { + if (!in_array($value, self::VALIDATE_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid validation option specified'); + } + $this->validate = $value; + } + + /** + * Gets whether to include object counts as the first yielded value + * + * @since 35.0.0 + */ + public function getCounts(): bool { + return $this->counts; + } + + /** + * Sets whether to include object counts as the first yielded value + * + * @since 35.0.0 + */ + public function setCounts(bool $counts): void { + $this->counts = $counts; + } + +} diff --git a/lib/public/Contacts/IManager.php b/lib/public/Contacts/IManager.php index 60abb18b38217..0c0677405631e 100644 --- a/lib/public/Contacts/IManager.php +++ b/lib/public/Contacts/IManager.php @@ -146,6 +146,15 @@ public function register(\Closure $callable); */ public function getUserAddressBooks(); + /** + * @param string $principalUri URI of the principal + * @param string[] $addressBookUris optionally specify which address books to load, or all if this array is empty + * + * @return \OCP\IAddressBook[] + * @since 23.0.0 + */ + public function getAddressBooksForPrincipal(string $principalUri, array $addressBookUris = []): array; + /** * removes all registered address book instances *