diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 622cf0cdd8b03..b97f6ceb9b72b 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -89,6 +89,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\\ImportCountEvent' => $baseDir . '/../lib/CalDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => $baseDir . '/../lib/CalDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => $baseDir . '/../lib/CalDAV/Import/ImportObjectEvent.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', @@ -170,6 +173,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', @@ -264,6 +273,9 @@ 'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php', '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\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 15b1b85370f92..da47db57dcce5 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -104,6 +104,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\\ImportCountEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportObjectEvent.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', @@ -185,6 +188,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', @@ -279,6 +288,9 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php', '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\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php', diff --git a/apps/dav/lib/CalDAV/Import/ImportCountEvent.php b/apps/dav/lib/CalDAV/Import/ImportCountEvent.php new file mode 100644 index 0000000000000..14676f82192fe --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportCountEvent.php @@ -0,0 +1,37 @@ +vevent + $this->vtodo + $this->vjournal; + } + + /** + * @return array{type: 'count', vevent: int, vtodo: int, vjournal: int} + */ + #[\Override] + public function jsonSerialize(): array { + return [ + 'type' => 'count', + 'vevent' => $this->vevent, + 'vtodo' => $this->vtodo, + 'vjournal' => $this->vjournal, + ]; + } +} diff --git a/apps/dav/lib/CalDAV/Import/ImportDisposition.php b/apps/dav/lib/CalDAV/Import/ImportDisposition.php new file mode 100644 index 0000000000000..4de18877e8f2c --- /dev/null +++ b/apps/dav/lib/CalDAV/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/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php index 052451ef19281..67c25f2d2d1c4 100644 --- a/apps/dav/lib/CalDAV/Import/ImportService.php +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -33,27 +33,20 @@ public function __construct( * * @param resource $source * - * @return array>> + * @return Generator * * @throws \InvalidArgumentException */ - public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): array { + public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): Generator { 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'); - } + return match ($options->getFormat()) { + 'ical' => $this->importProcess($source, $calendar, $options, $this->importText(...)), + 'jcal' => $this->importProcess($source, $calendar, $options, $this->importJson(...)), + 'xcal' => $this->importProcess($source, $calendar, $options, $this->importXml(...)), + default => throw new InvalidArgumentException('Invalid import format'), + }; } /** @@ -61,9 +54,9 @@ public function import($source, CalendarImpl $calendar, CalendarImportOptions $o * * @param resource $source * - * @return Generator<\Sabre\VObject\Component\VCalendar> + * @return Generator */ - public function importText($source): Generator { + public function importText($source, ?CalendarImportOptions $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -85,6 +78,14 @@ public function importText($source): Generator { $vObject = Reader::read($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 @@ -114,9 +115,9 @@ public function importText($source): Generator { * * @param resource $source * - * @return Generator<\Sabre\VObject\Component\VCalendar> + * @return Generator */ - public function importXml($source): Generator { + public function importXml($source, ?CalendarImportOptions $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -132,6 +133,14 @@ public function importXml($source): Generator { $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 @@ -160,10 +169,11 @@ public function importXml($source): Generator { * Generates object stream from a json formatted source (jcal) * * @param resource $source + * @param CalendarImportOptions|null $options * - * @return Generator<\Sabre\VObject\Component\VCalendar> + * @return Generator */ - public function importJson($source): Generator { + public function importJson($source, ?CalendarImportOptions $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -178,7 +188,27 @@ public function importJson($source): Generator { } } // calendar components - foreach ($importer->getBaseComponents() as $base) { + $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; @@ -225,14 +255,22 @@ private function findTimeZones(VCalendar $vObject): array { * @param CalendarImportOptions $options * @param callable $generator: Generator<\Sabre\VObject\Component\VCalendar> * - * @return array>> + * @return Generator */ - public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { + public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): Generator { $calendarId = $calendar->getKey(); $calendarUri = $calendar->getUri(); $principalUri = $calendar->getPrincipalUri(); - $outcome = []; - foreach ($generator($source) as $vObject) { + 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) { @@ -240,7 +278,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); } - $outcome['nbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); continue; } // determine if the object has more than one base component type @@ -254,7 +296,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); } - $outcome['mbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); continue 2; } } @@ -265,7 +311,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); } - $outcome['noid'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); continue; } $uid = $components[0]->UID->getValue(); @@ -273,7 +323,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt 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]; + 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]); @@ -290,7 +344,10 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt $objectId, $objectData ); - $outcome[$uid] = ['outcome' => 'created']; + yield new ImportObjectEvent( + disposition: ImportDisposition::Created, + identifier: $uid, + ); } else { [$cid, $oid] = explode('/', $objectId); if ($options->getSupersede()) { @@ -299,9 +356,15 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt $oid, $objectData ); - $outcome[$uid] = ['outcome' => 'updated']; + yield new ImportObjectEvent( + disposition: ImportDisposition::Updated, + identifier: $uid, + ); } else { - $outcome[$uid] = ['outcome' => 'exists']; + yield new ImportObjectEvent( + disposition: ImportDisposition::Exists, + identifier: $uid, + ); } } } catch (Exception $e) { @@ -309,11 +372,13 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e); } - $outcome[$uid] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + errors: [$errorMessage] + ); } } - - return $outcome; } /** diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index f22e38a41b47f..6563dd27a3343 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -10,6 +10,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; @@ -17,7 +18,7 @@ use Sabre\VObject\Reader; use Sabre\VObject\UUIDUtil; -class AddressBookImpl implements IAddressBookEnabled, ICreateContactFromString { +class AddressBookImpl implements ICreateContactFromString, IAddressBookEnabled, IAddressBookWritable { /** * AddressBookImpl constructor. @@ -55,6 +56,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 * @@ -348,6 +357,19 @@ public function isEnabled(): bool { return true; } + #[\Override] + 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..54f00914d75fe --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportCountEvent.php @@ -0,0 +1,31 @@ +vcard; + } + + /** + * @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..6e8a073851452 --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportService.php @@ -0,0 +1,204 @@ + + * + * @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()) { + 'vcf' => $this->importProcess($source, $addressBook, $options, $this->importText(...)), + default => throw new InvalidArgumentException('Invalid import format'), + }; + } + + /** + * Generates object stream from a text formatted source (vcf) + * + * @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(); + // object counts before streaming if requested + if ($options?->getCounts()) { + yield 'counts' => [ + 'VCARD' => count($structure['VCARD']) + ]; + } + // card components + foreach ($structure['VCARD'] as $cid => $instances) { + /** @var array $instances */ + // extract all instances of component and unserialize to object + $instance = $instances[0]; + $sObjectContents = $importer->extract($instance[2], $instance[3]); + /** @var VCard $vObject */ + $vObject = Reader::read($sObjectContents); + yield $vObject; + } + } + + /** + * Import objects + * + * @since 32.0.0 + * + * @param resource $source + * @param ContactsImportOptions $options + * @param callable $generator: Generator + * + * @return Generator + */ + public function importProcess($source, AddressBookImpl $addressBook, ContactsImportOptions $options, callable $generator): Generator { + $addressBookId = (int)$addressBook->getKey(); + foreach ($generator($source, $options) as $key => $value) { + if ($key === 'counts') { + yield new ImportCountEvent( + vcard: $value['VCARD'] ?? 0, + ); + continue; + } + $vObject = $value; + // determine if the object has a uid + if (!isset($vObject->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 = $vObject->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 + $objectEntry = $this->backend->getCardByUID($addressBookId, $uid); + $objectData = $vObject->serialize(); + try { + if ($objectEntry === null) { + $objectUri = UUIDUtil::getUUID(); + $this->backend->createCard( + $addressBookId, + $objectUri, + $objectData + ); + yield new ImportObjectEvent( + disposition: ImportDisposition::Created, + identifier: $uid, + ); + } else { + $objectUri = $objectEntry['uri']; + if ($options->getSupersede()) { + $this->backend->updateCard( + $addressBookId, + $objectUri, + $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 VCard $vObject + * @param bool $repair attempt to repair the component + * @param int $level minimum level of issues to return + * @return list + */ + private function componentValidate(VCard $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..2fc9a8dc3080f --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/TextImporter.php @@ -0,0 +1,159 @@ + []]; + + /** + * @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); + $data = fread($this->source, $end - $start); + if ($data === false) { + throw new Exception('Failed to read from source stream'); + } + return $data; + } +} diff --git a/apps/dav/lib/Command/ImportCalendar.php b/apps/dav/lib/Command/ImportCalendar.php index 74f8e8901eae7..def396d19869f 100644 --- a/apps/dav/lib/Command/ImportCalendar.php +++ b/apps/dav/lib/Command/ImportCalendar.php @@ -9,6 +9,9 @@ use InvalidArgumentException; use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Import\ImportCountEvent; +use OCA\DAV\CalDAV\Import\ImportDisposition; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\Calendar\CalendarImportOptions; use OCP\Calendar\IManager; @@ -100,80 +103,76 @@ protected function execute(InputInterface $input, OutputInterface $output): int $options->setValidate($validation); } $options->setFormat($format); + $options->setCounts(true); // 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) { + $stream = fopen($location, 'r'); + if ($stream === false) { throw new InvalidArgumentException("Location <$location> is not valid. Cannot 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) { + $stdin = fopen('php://stdin', 'r'); + if ($stdin === false) { throw new InvalidArgumentException('Cannot 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); + $tempPath = $this->tempManager->getTemporaryFile(); + $stream = fopen($tempPath, 'w+'); + while (!feof($stdin)) { + fwrite($stream, fread($stdin, 8192)); } + fclose($stdin); + fseek($stream, 0); } - $timeFinished = microtime(true); - - // summarize the outcome + $timeStarted = microtime(true); $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; + try { + foreach ($this->importService->import($stream, $calendar, $options) as $event) { + if ($event instanceof ImportCountEvent) { + $output->writeln('Total objects to import: ' . $event->total()); + if ($showCreated || $showUpdated || $showSkipped || $showErrors) { + $output->writeln(''); } + continue; + } + if (!$event instanceof ImportObjectEvent) { + continue; + } + + switch ($event->disposition) { + case ImportDisposition::Created: + $totalCreated++; + if ($showCreated) { + $output->writeln(['created: ' . ($event->identifier ?? 'unknown')]); + } + break; + case ImportDisposition::Updated: + $totalUpdated++; + if ($showUpdated) { + $output->writeln(['updated: ' . ($event->identifier ?? 'unknown')]); + } + break; + case ImportDisposition::Exists: + $totalSkipped++; + if ($showSkipped) { + $output->writeln(['skipped: ' . ($event->identifier ?? 'unknown')]); + } + break; + case ImportDisposition::Error: + $totalErrors++; + if ($showErrors) { + $output->writeln(['errors: ' . ($event->identifier ?? 'unknown')]); + $output->writeln($event->errors); + } + break; } } + } finally { + fclose($stream); } + $timeFinished = microtime(true); $output->writeln([ '', 'Import Completed', diff --git a/apps/dav/lib/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php new file mode 100644 index 0000000000000..cd66c139f89ed --- /dev/null +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -0,0 +1,107 @@ + | DataResponse + * + * 200: data in requested format + * 400: invalid parameters + * 401: user not authorized + */ + #[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')] + #[UserRateLimit(limit: 1, period: 60)] + #[NoAdminRequired] + public function export(string $target, ?string $type = null, ?array $options = null, ?string $user = null) { + $calendarId = $target; + $format = $type ?? 'ical'; + $rangeStart = isset($options['rangeStart']) ? (string)$options['rangeStart'] : null; + $rangeCount = isset($options['rangeCount']) ? (int)$options['rangeCount'] : null; + // 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); + } + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new CalendarExportOptions(); + $options->setRangeStart($rangeStart); + $options->setRangeCount($rangeCount); + // evaluate if provided format is supported + if (!in_array($format, ExportService::FORMATS, true)) { + return new DataResponse(['error' => "Format <$format> is not valid."], Http::STATUS_BAD_REQUEST); + } + $options->setFormat($format); + // construct response + $contentType = match (strtolower($options->getFormat())) { + 'jcal' => 'application/calendar+json; charset=UTF-8', + 'xcal' => 'application/calendar+xml; charset=UTF-8', + default => 'text/calendar; charset=UTF-8' + }; + $response = new StreamTraversableResponse($this->exportService->export($calendar, $options), Http::STATUS_OK, ['Content-Type' => $contentType]); + $response->cacheFor(0); + + return $response; + } +} diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php new file mode 100644 index 0000000000000..83c40fa5651b4 --- /dev/null +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -0,0 +1,149 @@ +, total: non-negative-int} + */ +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\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\StreamTraversableResponse; +use OCP\AppFramework\OCSController; +use OCP\Calendar\CalendarImportOptions; +use OCP\Calendar\IManager; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\ITempManager; +use OCP\IUserManager; +use OCP\IUserSession; + +class CalendarImportController extends OCSController { + + public function __construct( + IRequest $request, + private IUserSession $userSession, + private IUserManager $userManager, + private IGroupManager $groupManager, + private ITempManager $tempManager, + private IManager $calendarManager, + private ImportService $importService, + ) { + parent::__construct(Application::APP_ID, $request); + } + + /** + * Import calendar data + * + * @param string $transaction client generated transaction id + * @param string $target calendar id + * @param array{format?:string, validation?:0|1|2, errors?:0|1, supersede?:bool, showCreated?:bool, showUpdated?:bool, showSkipped?:bool, showErrors?:bool} $options configuration options + * @param string $data calendar data + * @param string|null $user system user id + * + * @return StreamTraversableResponse | DataResponse + * + * 200: NDJSON stream of import event objects + * 400: invalid parameters + * 401: user not authorized + */ + #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] + #[UserRateLimit(limit: 10, period: 3600)] + #[NoAdminRequired] + public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamTraversableResponse { + $calendarId = $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 calendar and evaluate if import is supported and writeable + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => "Calendar <$calendarId> not found"], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof CalendarImpl) { + return new DataResponse(['error' => "Calendar <$calendarId> does not support this function"], Http::STATUS_BAD_REQUEST); + } + if (!$calendar->isWritable()) { + return new DataResponse(['error' => "Calendar <$calendarId> is not writeable"], Http::STATUS_BAD_REQUEST); + } + if ($calendar->isDeleted()) { + return new DataResponse(['error' => "Calendar <$calendarId> is deleted"], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new CalendarImportOptions(); + $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, $calendar, $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 StreamTraversableResponse($stream, Http::STATUS_OK, ['Content-Type' => 'application/x-ndjson']); + } +} diff --git a/apps/dav/lib/Controller/ContactsImportController.php b/apps/dav/lib/Controller/ContactsImportController.php new file mode 100644 index 0000000000000..fe1264a5f3b1d --- /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\StreamTraversableResponse; +use OCP\AppFramework\OCSController; +use OCP\Contacts\ContactsImportOptions; +use OCP\Contacts\IManager; +use OCP\IAddressBookWritable; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\ITempManager; +use OCP\IUserManager; +use OCP\IUserSession; + +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 StreamTraversableResponse | 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|StreamTraversableResponse { + $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 StreamTraversableResponse($stream, Http::STATUS_OK, ['Content-Type' => 'application/x-ndjson']); + } +} diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 8dd94d6d4cf25..84154272c8df1 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -13,6 +13,8 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarImpl; use OCA\DAV\CalDAV\Export\ExportService; +use OCA\DAV\CalDAV\Import\ImportDisposition; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\App\IAppManager; use OCP\Calendar\CalendarExportOptions; @@ -331,24 +333,22 @@ public function importCalendarsV2(IUser $user, IImportSource $importSource, Outp rewind($tempFile); // import calendar data + $options = new CalendarImportOptions(); + $options->setFormat($calendarMeta['format'] ?? 'ical'); + $options->setErrors(0); + $options->setValidate(1); + $options->setSupersede(true); + try { - $options = new CalendarImportOptions(); - $options->setFormat($calendarMeta['format'] ?? 'ical'); - $options->setErrors(0); - $options->setValidate(1); - $options->setSupersede(true); - - $outcome = $this->importService->import( - $tempFile, - $calendar, - $options + $this->importSummary( + $calendarMeta['label'] ?? $calendarMeta['uri'], + $this->importService->import($tempFile, $calendar, $options), + $output ); } finally { fclose($tempFile); } - $this->importSummary($calendarMeta['label'] ?? $calendarMeta['uri'], $outcome, $output); - $importCount++; } catch (Throwable $e) { $output->writeln('Failed to import calendar "' . ($calendarMeta['uri'] ?? 'unknown') . '", skipping…'); @@ -442,16 +442,14 @@ public function importCalendarsV1(IUser $user, IImportSource $importSource, Outp $options->setSupersede(true); try { - $outcome = $this->importService->import( - $tempFile, - $calendar, - $options + $this->importSummary( + $calendarName ?? $calendarUri, + $this->importService->import($tempFile, $calendar, $options), + $output ); } finally { fclose($tempFile); } - - $this->importSummary($calendarName ?? $calendarUri, $outcome, $output); } catch (Throwable $e) { $output->writeln("Failed to import calendar \"$filename\", skipping…"); continue; @@ -515,19 +513,25 @@ public function importSubscriptions(IUser $user, IImportSource $importSource, Ou } } - private function importSummary(string $label, array $outcome, OutputInterface $output): void { + /** + * @param iterable $stream + */ + private function importSummary(string $label, iterable $stream, OutputInterface $output): void { $created = 0; $updated = 0; $skipped = 0; $errors = 0; - foreach ($outcome as $result) { - match ($result['outcome'] ?? null) { - 'created' => $created++, - 'updated' => $updated++, - 'exists' => $skipped++, - 'error' => $errors++, - default => null, + foreach ($stream as $event) { + if (!$event instanceof ImportObjectEvent) { + continue; + } + + match ($event->disposition) { + ImportDisposition::Created => $created++, + ImportDisposition::Updated => $updated++, + ImportDisposition::Exists => $skipped++, + ImportDisposition::Error => $errors++, }; } diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index e69d5be01d9a0..f8346e47d5fdf 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1576,6 +1576,627 @@ } } } + }, + "/ocs/v2.php/calendar/export": { + "post": { + "operationId": "calendar_export-export", + "summary": "Export calendar data", + "tags": [ + "calendar_export" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "target" + ], + "properties": { + "target": { + "type": "string", + "description": "calendar id" + }, + "type": { + "type": "string", + "nullable": true, + "default": null, + "description": "data format" + }, + "options": { + "type": "object", + "default": null, + "description": "configuration options", + "required": [ + "rangeStart", + "rangeCount" + ], + "properties": { + "rangeStart": { + "type": "string" + }, + "rangeCount": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "data in requested format", + "content": { + "text/calendar; charset=UTF-8": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/calendar+json; charset=UTF-8": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/calendar+xml; charset=UTF-8": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/calendar/import": { + "post": { + "operationId": "calendar_import-import", + "summary": "Import calendar data", + "tags": [ + "calendar_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "transaction", + "target", + "options", + "data" + ], + "properties": { + "transaction": { + "type": "string", + "description": "client generated transaction id" + }, + "target": { + "type": "string", + "description": "calendar id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] + }, + "errors": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ] + }, + "supersede": { + "type": "boolean" + }, + "showCreated": { + "type": "boolean" + }, + "showUpdated": { + "type": "boolean" + }, + "showSkipped": { + "type": "boolean" + }, + "showErrors": { + "type": "boolean" + } + } + }, + "data": { + "type": "string", + "description": "calendar data" + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "NDJSON stream of import event objects", + "content": { + "application/x-ndjson": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/contacts/import": { + "post": { + "operationId": "contacts_import-import", + "summary": "Import contacts data", + "tags": [ + "contacts_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "transaction", + "target", + "options", + "data" + ], + "properties": { + "transaction": { + "type": "string", + "description": "client generated transaction id" + }, + "target": { + "type": "string", + "description": "address book id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] + }, + "errors": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ] + }, + "supersede": { + "type": "boolean" + } + } + }, + "data": { + "type": "string", + "description": "contacts data" + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "NDJSON stream of import event objects", + "content": { + "application/x-ndjson": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } } }, "tags": [] diff --git a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php index 0cfafae22bcdd..066d77ddf3e70 100644 --- a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -8,6 +8,9 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Import\ImportCountEvent; +use OCA\DAV\CalDAV\Import\ImportDisposition; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\Calendar\CalendarImportOptions; use PHPUnit\Framework\MockObject\MockObject; @@ -19,7 +22,6 @@ class ImportServiceTest extends \Test\TestCase { private ImportService $service; private CalendarImpl|MockObject $calendar; private CalDavBackend|MockObject $backend; - private array $importResults = []; protected function setUp(): void { parent::setUp(); @@ -55,6 +57,7 @@ public function testImport(): void { // construct import options $options = new CalendarImportOptions(); $options->setFormat('ical'); + $options->setCounts(true); // Mock calendar methods $this->calendar->expects($this->once()) @@ -79,13 +82,18 @@ public function testImport(): void { ); // Act - $result = $this->service->import($stream, $this->calendar, $options); + $result = iterator_to_array($this->service->import($stream, $this->calendar, $options), false); // 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']); + $this->assertCount(2, $result, 'Import result should contain counts and one item'); + $this->assertInstanceOf(ImportCountEvent::class, $result[0]); + $this->assertSame(1, $result[0]->vevent); + $this->assertSame(0, $result[0]->vtodo); + $this->assertSame(0, $result[0]->vjournal); + $this->assertInstanceOf(ImportObjectEvent::class, $result[1]); + $this->assertSame('96a0e6b1-d886-4a55-a60d-152b31401dcc', $result[1]->identifier); + $this->assertSame(ImportDisposition::Created, $result[1]->disposition); } public function testImportWithMultiLineUID(): void { @@ -139,12 +147,13 @@ public function testImportWithMultiLineUID(): void { ); // Act - $result = $this->service->import($stream, $this->calendar, $options); + $result = iterator_to_array($this->service->import($stream, $this->calendar, $options), false); // Assert $this->assertIsArray($result); $this->assertCount(1, $result, 'Import result should contain one item'); - $this->assertArrayHasKey($longUID, $result); - $this->assertEquals('created', $result[$longUID]['outcome']); + $this->assertInstanceOf(ImportObjectEvent::class, $result[0]); + $this->assertSame($longUID, $result[0]->identifier); + $this->assertSame(ImportDisposition::Created, $result[0]->disposition); } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 198c691981d62..c3fd3f1b11052 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -304,6 +304,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', @@ -622,6 +623,7 @@ 'OCP\\Http\\WellKnown\\JrdResponse' => $baseDir . '/lib/public/Http/WellKnown/JrdResponse.php', 'OCP\\IAddressBook' => $baseDir . '/lib/public/IAddressBook.php', 'OCP\\IAddressBookEnabled' => $baseDir . '/lib/public/IAddressBookEnabled.php', + 'OCP\\IAddressBookWritable' => $baseDir . '/lib/public/IAddressBookWritable.php', 'OCP\\IAppConfig' => $baseDir . '/lib/public/IAppConfig.php', 'OCP\\IAvatar' => $baseDir . '/lib/public/IAvatar.php', 'OCP\\IAvatarManager' => $baseDir . '/lib/public/IAvatarManager.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 36960602bd5e3..e024f2f707230 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', ), @@ -345,6 +345,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', @@ -663,6 +664,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Http\\WellKnown\\JrdResponse' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/JrdResponse.php', 'OCP\\IAddressBook' => __DIR__ . '/../../..' . '/lib/public/IAddressBook.php', 'OCP\\IAddressBookEnabled' => __DIR__ . '/../../..' . '/lib/public/IAddressBookEnabled.php', + 'OCP\\IAddressBookWritable' => __DIR__ . '/../../..' . '/lib/public/IAddressBookWritable.php', 'OCP\\IAppConfig' => __DIR__ . '/../../..' . '/lib/public/IAppConfig.php', 'OCP\\IAvatar' => __DIR__ . '/../../..' . '/lib/public/IAvatar.php', 'OCP\\IAvatarManager' => __DIR__ . '/../../..' . '/lib/public/IAvatarManager.php', diff --git a/lib/private/ContactsManager.php b/lib/private/ContactsManager.php index e19efb243b8f3..cb724ed68aa8a 100644 --- a/lib/private/ContactsManager.php +++ b/lib/private/ContactsManager.php @@ -153,6 +153,22 @@ public function getUserAddressBooks(): array { return $this->addressBooks; } + #[\Override] + 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/Calendar/CalendarImportOptions.php b/lib/public/Calendar/CalendarImportOptions.php index f5f330a425497..c302d916de717 100644 --- a/lib/public/Calendar/CalendarImportOptions.php +++ b/lib/public/Calendar/CalendarImportOptions.php @@ -62,6 +62,7 @@ final class CalendarImportOptions { private bool $supersede = false; private int $errors = self::ERROR_FAIL; private int $validate = self::VALIDATE_SKIP; + private bool $counts = false; /** * Gets the import format @@ -154,4 +155,22 @@ public function setValidate(int $value): void { $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/ContactsImportOptions.php b/lib/public/Contacts/ContactsImportOptions.php new file mode 100644 index 0000000000000..1543a4dad1df0 --- /dev/null +++ b/lib/public/Contacts/ContactsImportOptions.php @@ -0,0 +1,177 @@ +format; + } + + /** + * Sets the import format + * + * @param 'vcf'|'jcf'|'xcf' $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 * diff --git a/lib/public/IAddressBookWritable.php b/lib/public/IAddressBookWritable.php new file mode 100644 index 0000000000000..35f44f15a054f --- /dev/null +++ b/lib/public/IAddressBookWritable.php @@ -0,0 +1,26 @@ + 'text/plain']); + + $headers = $response->getHeaders(); + $this->assertEquals('text/plain', $headers['Content-Type']); + $this->assertEquals(200, $response->getStatus()); + } + + public function testCallback() { + $count = 0; + $generator = function () use (&$count) { + $count++; + yield 'chunk1'; + $count++; + yield 'chunk2'; + }; + $response = new StreamTraversableResponse($generator(), 200, ['Content-Type' => 'text/plain']); + $output = $this->createMock(IOutput::class); + $output->expects($this->exactly(2)) + ->method('setOutput') + ->with($this->callback(function ($chunk) { + return in_array($chunk, ['chunk1', 'chunk2'], true); + })); + + $response->callback($output); + $this->assertEquals($count, 2); + } + +}