From 0d0df4dcd9bee67726113c4cbf174b39baf73bd4 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Thu, 18 Sep 2025 15:26:48 -0400 Subject: [PATCH 1/3] feat: OCS Calendar Export + Import Signed-off-by: SebastianKrupinski --- .../composer/composer/autoload_classmap.php | 2 + .../dav/composer/composer/autoload_static.php | 2 + .../Controller/CalendarExportController.php | 109 +++ .../Controller/CalendarImportController.php | 193 +++++ apps/dav/openapi.json | 780 ++++++++++++++++++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + .../Http/StreamGeneratorResponse.php | 61 ++ openapi.json | 780 ++++++++++++++++++ .../Http/StreamGeneratorResponseTest.php | 46 ++ 10 files changed, 1975 insertions(+) create mode 100644 apps/dav/lib/Controller/CalendarExportController.php create mode 100644 apps/dav/lib/Controller/CalendarImportController.php create mode 100644 lib/public/AppFramework/Http/StreamGeneratorResponse.php create mode 100644 tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2ca5cf66f901f..40f3ec969d7c5 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -261,6 +261,8 @@ '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\\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 c35dd97c02c0e..c167b0d053588 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -276,6 +276,8 @@ 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\\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/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php new file mode 100644 index 0000000000000..4713a23953db9 --- /dev/null +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -0,0 +1,109 @@ +} $options configuration options + * @param string|null $user system user id + * + * @return StreamGeneratorResponse | DataResponse + * + * 200: data in requested format + * 400: invalid parameters + * 401: user not authorized + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + #[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')] + #[UserRateLimit(limit: 60, period: 60)] + #[NoAdminRequired] + public function index(string $id, ?string $type = null, ?array $options = null, ?string $user = null) { + $userId = $user; + $calendarId = $id; + $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 ($userId !== null) { + if ($this->userSession->getUser()->getUID() !== $userId + && $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($userId)) { + 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 StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType, Http::STATUS_OK); + $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..365c6db45d5ed --- /dev/null +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -0,0 +1,193 @@ +, errors?:int<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 DataResponse, total: int<0,max>}, updated?: array{items: list, total: int<0,max>}, skipped?: array{items: list, total: int<0, max>}, errors?: array{items: list, total: int<0, max>}}, array{}> + * + * 200: calendar data + * 400: invalid request + * 401: user not authorized + */ + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] + #[UserRateLimit(limit: 1, period: 60)] + #[NoAdminRequired] + public function index(string $id, array $options, string $data, ?string $user = null): DataResponse { + $userId = $user; + $calendarId = $id; + $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; + $showCreated = $options['showCreated'] ?? false; + $showUpdated = $options['showUpdated'] ?? false; + $showSkipped = $options['showSkipped'] ?? false; + $showErrors = $options['showErrors'] ?? false; + // evaluate if user is logged in and has permissions + if (!$this->userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($userId !== null) { + if ($this->userSession->getUser()->getUID() !== $userId + && $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($userId)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + } 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> dose 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); + } + // process the data + $timeStarted = microtime(true); + try { + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + fwrite($tempFile, $data); + unset($data); + fseek($tempFile, 0); + $outcome = $this->importService->import($tempFile, $calendar, $options); + } catch (\Throwable $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } finally { + fclose($tempFile); + } + $timeFinished = microtime(true); + + // summarize the outcome + $objectsCreated = []; + $objectsUpdated = []; + $objectsSkipped = []; + $objectsErrors = []; + $totalCreated = 0; + $totalUpdated = 0; + $totalSkipped = 0; + $totalErrors = 0; + + if ($outcome !== []) { + foreach ($outcome as $id => $result) { + if (isset($result['outcome'])) { + switch ($result['outcome']) { + case 'created': + $totalCreated++; + if ($showCreated) { + $objectsCreated[] = $id; + } + break; + case 'updated': + $totalUpdated++; + if ($showUpdated) { + $objectsUpdated[] = $id; + } + break; + case 'exists': + $totalSkipped++; + if ($showSkipped) { + $objectsSkipped[] = $id; + } + break; + case 'error': + $totalErrors++; + if ($showErrors) { + $objectsErrors[] = $id; + } + break; + } + } + + } + } + $summary = [ + 'time' => ($timeFinished - $timeStarted), + 'created' => ['total' => $totalCreated, 'items' => $objectsCreated], + 'updated' => ['total' => $totalUpdated, 'items' => $objectsUpdated], + 'skipped' => ['total' => $totalSkipped, 'items' => $objectsSkipped], + 'errors' => ['total' => $totalErrors, 'items' => $objectsErrors], + ]; + + return new DataResponse($summary, Http::STATUS_OK); + } +} diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index 344d37815318c..1a9e030dfd35d 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1163,6 +1163,786 @@ } } } + }, + "/ocs/v2.php/calendar/export": { + "post": { + "operationId": "calendar_export-index", + "summary": "Export calendar data", + "tags": [ + "calendar_export" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "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" + }, + "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-index", + "summary": "Import calendar data", + "tags": [ + "calendar_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "options", + "data" + ], + "properties": { + "id": { + "type": "string", + "description": "calendar id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 2 + }, + "errors": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 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": "calendar data", + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "invalid request", + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + } + } + } + }, + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "500": { + "description": "", + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index fe73bc0443d9a..6fcdffe6e692f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -127,6 +127,7 @@ 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => $baseDir . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => $baseDir . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', + 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\TemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/TemplateResponse.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f4d201e709a0c..cf53ea04c0a21 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -168,6 +168,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', + 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\TemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TemplateResponse.php', diff --git a/lib/public/AppFramework/Http/StreamGeneratorResponse.php b/lib/public/AppFramework/Http/StreamGeneratorResponse.php new file mode 100644 index 0000000000000..d3b2e2d5c1cff --- /dev/null +++ b/lib/public/AppFramework/Http/StreamGeneratorResponse.php @@ -0,0 +1,61 @@ + + * @template-extends Response> + */ +class StreamGeneratorResponse extends Response implements ICallbackResponse { + protected $generator; + + /** + * @since 32.0.0 + * + * @param Generator $generator the function to call to generate the response + * @param string $contentType http response content type e.g. 'application/json; charset=UTF-8' + * @param S $status http response status + * @param array|null $headers additional headers + */ + public function __construct(Generator $generator, string $contentType, int $status = Http::STATUS_OK, ?array $headers = []) { + parent::__construct(); + + $this->generator = $generator; + + $this->setStatus($status); + $this->addHeader('Content-Type', $contentType); + + foreach ($headers as $key => $value) { + $this->addHeader($key, $value); + } + } + + /** + * Streams content directly to client + * + * @since 32.0.0 + * + * @param IOutput $output a small wrapper that handles output + */ + public function callback(IOutput $output) { + + foreach ($this->generator as $chunk) { + print($chunk); + flush(); + } + + } + +} diff --git a/openapi.json b/openapi.json index ba4ee767a4b16..9e50bc0c73db5 100644 --- a/openapi.json +++ b/openapi.json @@ -20192,6 +20192,786 @@ } } }, + "/ocs/v2.php/calendar/export": { + "post": { + "operationId": "dav-calendar_export-index", + "summary": "Export calendar data", + "tags": [ + "dav/calendar_export" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "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" + }, + "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": "dav-calendar_import-index", + "summary": "Import calendar data", + "tags": [ + "dav/calendar_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "options", + "data" + ], + "properties": { + "id": { + "type": "string", + "description": "calendar id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 2 + }, + "errors": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 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": "calendar data", + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "invalid request", + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + } + } + } + }, + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "500": { + "description": "", + "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" + }, + "time": { + "type": "number", + "format": "double" + }, + "created": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "updated": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "skipped": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "errors": { + "type": "object", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, "/index.php/apps/federatedfilesharing/createFederatedShare": { "post": { "operationId": "federatedfilesharing-mount_public_link-create-federated-share", diff --git a/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php b/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php new file mode 100644 index 0000000000000..96660ba55e276 --- /dev/null +++ b/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php @@ -0,0 +1,46 @@ +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 StreamGeneratorResponse($generator(), 'text/plain'); + $output = $this->createMock(\OCP\AppFramework\Http\IOutput::class); + + $response->callback($output); + $this->assertEquals($count, 2); + } + +} From fe2c94282c333288084cdc191fe52942ce403de2 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Wed, 15 Oct 2025 19:52:58 -0400 Subject: [PATCH 2/3] fixup! feat: OCS Calendar Export + Import Signed-off-by: SebastianKrupinski --- .../Controller/CalendarExportController.php | 16 ++++----- .../Controller/CalendarImportController.php | 19 +++++----- apps/dav/openapi.json | 36 +++++++++++++++---- .../Http/StreamGeneratorResponse.php | 10 ++---- openapi.json | 36 +++++++++++++++---- 5 files changed, 78 insertions(+), 39 deletions(-) diff --git a/apps/dav/lib/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php index 4713a23953db9..2d1cc896e5769 100644 --- a/apps/dav/lib/Controller/CalendarExportController.php +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -43,21 +43,19 @@ public function __construct( * * @param string $id calendar id * @param string|null $type data format - * @param array{rangeStart:string,rangeCount:int<1,max>} $options configuration options + * @param array{rangeStart:string,rangeCount:positive-int} $options configuration options * @param string|null $user system user id * - * @return StreamGeneratorResponse | DataResponse + * @return StreamGeneratorResponse | DataResponse * * 200: data in requested format * 400: invalid parameters * 401: user not authorized */ - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] #[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')] - #[UserRateLimit(limit: 60, period: 60)] + #[UserRateLimit(limit: 1, period: 60)] #[NoAdminRequired] - public function index(string $id, ?string $type = null, ?array $options = null, ?string $user = null) { - $userId = $user; + public function export(string $id, ?string $type = null, ?array $options = null, ?string $user = null) { $calendarId = $id; $format = $type ?? 'ical'; $rangeStart = isset($options['rangeStart']) ? (string)$options['rangeStart'] : null; @@ -66,12 +64,12 @@ public function index(string $id, ?string $type = null, ?array $options = null, if (!$this->userSession->isLoggedIn()) { return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - if ($userId !== null) { - if ($this->userSession->getUser()->getUID() !== $userId + 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($userId)) { + if (!$this->userManager->userExists($user)) { return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); } } else { diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php index 365c6db45d5ed..96dd175103952 100644 --- a/apps/dav/lib/Controller/CalendarImportController.php +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -8,13 +8,16 @@ namespace OCA\DAV\Controller; use InvalidArgumentException; + +/** + * @psalm-type CalendarImportResult = array{items: list, 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\OpenAPI; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; @@ -44,22 +47,20 @@ public function __construct( * Import calendar data * * @param string $id calendar id - * @param array{format?:string, validation?:int<0,2>, errors?:int<0,1>, supersede?:bool, showCreated?:bool, showUpdated?:bool, showSkipped?:bool, showErrors?:bool} $options configuration options + * @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 DataResponse, total: int<0,max>}, updated?: array{items: list, total: int<0,max>}, skipped?: array{items: list, total: int<0, max>}, errors?: array{items: list, total: int<0, max>}}, array{}> + * @return DataResponse, total: non-negative-int}, updated?: array{items: list, total: non-negative-int}, skipped?: array{items: list, total: non-negative-int}, errors?: array{items: list, total: non-negative-int}}, array{}> * * 200: calendar data * 400: invalid request * 401: user not authorized */ - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] #[UserRateLimit(limit: 1, period: 60)] #[NoAdminRequired] - public function index(string $id, array $options, string $data, ?string $user = null): DataResponse { - $userId = $user; + public function import(string $id, array $options, string $data, ?string $user = null): DataResponse { $calendarId = $id; $format = isset($options['format']) ? $options['format'] : null; $validation = isset($options['validation']) ? (int)$options['validation'] : null; @@ -73,12 +74,12 @@ public function index(string $id, array $options, string $data, ?string $user = if (!$this->userSession->isLoggedIn()) { return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - if ($userId !== null) { - if ($this->userSession->getUser()->getUID() !== $userId + 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($userId)) { + if (!$this->userManager->userExists($user)) { return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); } } else { diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index 1a9e030dfd35d..99798a0e7de0d 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1166,7 +1166,7 @@ }, "/ocs/v2.php/calendar/export": { "post": { - "operationId": "calendar_export-index", + "operationId": "calendar_export-export", "summary": "Export calendar data", "tags": [ "calendar_export" @@ -1243,7 +1243,24 @@ ], "responses": { "200": { - "description": "data in requested format" + "description": "data in requested format", + "content": { + "text/calendar; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+json; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+xml; charset=UTF-8": { + "schema": { + "anyOf": [] + } + } + } }, "400": { "description": "invalid parameters", @@ -1347,7 +1364,7 @@ }, "/ocs/v2.php/calendar/import": { "post": { - "operationId": "calendar_import-index", + "operationId": "calendar_import-import", "summary": "Import calendar data", "tags": [ "calendar_import" @@ -1386,14 +1403,19 @@ "validation": { "type": "integer", "format": "int64", - "minimum": 0, - "maximum": 2 + "enum": [ + 0, + 1, + 2 + ] }, "errors": { "type": "integer", "format": "int64", - "minimum": 0, - "maximum": 1 + "enum": [ + 0, + 1 + ] }, "supersede": { "type": "boolean" diff --git a/lib/public/AppFramework/Http/StreamGeneratorResponse.php b/lib/public/AppFramework/Http/StreamGeneratorResponse.php index d3b2e2d5c1cff..4f8a77cbcbfca 100644 --- a/lib/public/AppFramework/Http/StreamGeneratorResponse.php +++ b/lib/public/AppFramework/Http/StreamGeneratorResponse.php @@ -3,8 +3,8 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-only + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCP\AppFramework\Http; @@ -12,7 +12,7 @@ use OCP\AppFramework\Http; /** - * @since 32.0.0 + * @since 33.0.0 * * @template S of Http::STATUS_* * @template H of array @@ -22,8 +22,6 @@ class StreamGeneratorResponse extends Response implements ICallbackResponse { protected $generator; /** - * @since 32.0.0 - * * @param Generator $generator the function to call to generate the response * @param string $contentType http response content type e.g. 'application/json; charset=UTF-8' * @param S $status http response status @@ -45,8 +43,6 @@ public function __construct(Generator $generator, string $contentType, int $stat /** * Streams content directly to client * - * @since 32.0.0 - * * @param IOutput $output a small wrapper that handles output */ public function callback(IOutput $output) { diff --git a/openapi.json b/openapi.json index 9e50bc0c73db5..cda2a70a18805 100644 --- a/openapi.json +++ b/openapi.json @@ -20194,7 +20194,7 @@ }, "/ocs/v2.php/calendar/export": { "post": { - "operationId": "dav-calendar_export-index", + "operationId": "dav-calendar_export-export", "summary": "Export calendar data", "tags": [ "dav/calendar_export" @@ -20271,7 +20271,24 @@ ], "responses": { "200": { - "description": "data in requested format" + "description": "data in requested format", + "content": { + "text/calendar; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+json; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+xml; charset=UTF-8": { + "schema": { + "anyOf": [] + } + } + } }, "400": { "description": "invalid parameters", @@ -20375,7 +20392,7 @@ }, "/ocs/v2.php/calendar/import": { "post": { - "operationId": "dav-calendar_import-index", + "operationId": "dav-calendar_import-import", "summary": "Import calendar data", "tags": [ "dav/calendar_import" @@ -20414,14 +20431,19 @@ "validation": { "type": "integer", "format": "int64", - "minimum": 0, - "maximum": 2 + "enum": [ + 0, + 1, + 2 + ] }, "errors": { "type": "integer", "format": "int64", - "minimum": 0, - "maximum": 1 + "enum": [ + 0, + 1 + ] }, "supersede": { "type": "boolean" From 0ad93ad2a2986eba40d7a53a9ca518d29c3cc9d4 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Tue, 9 Jun 2026 17:25:15 -0400 Subject: [PATCH 3/3] refactor: import to stream progess for user feed back Signed-off-by: SebastianKrupinski --- .../composer/composer/autoload_classmap.php | 4 + .../dav/composer/composer/autoload_static.php | 8 +- .../lib/CalDAV/Import/ImportCountEvent.php | 34 +++++ .../lib/CalDAV/Import/ImportDisposition.php | 16 +++ apps/dav/lib/CalDAV/Import/ImportEvent.php | 14 ++ .../lib/CalDAV/Import/ImportObjectEvent.php | 39 ++++++ apps/dav/lib/CalDAV/Import/ImportService.php | 121 +++++++++++++----- apps/dav/lib/Command/ImportCalendar.php | 113 ++++++++-------- .../Controller/CalendarExportController.php | 1 - .../Controller/CalendarImportController.php | 90 +++---------- .../lib/UserMigration/CalendarMigrator.php | 57 +++++---- .../unit/CalDAV/Import/ImportServiceTest.php | 25 ++-- lib/public/Calendar/CalendarImportOptions.php | 19 +++ 13 files changed, 344 insertions(+), 197 deletions(-) create mode 100644 apps/dav/lib/CalDAV/Import/ImportCountEvent.php create mode 100644 apps/dav/lib/CalDAV/Import/ImportDisposition.php create mode 100644 apps/dav/lib/CalDAV/Import/ImportEvent.php create mode 100644 apps/dav/lib/CalDAV/Import/ImportObjectEvent.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 40f3ec969d7c5..91e4b33551ad5 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -87,6 +87,10 @@ '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\\ImportEvent' => $baseDir . '/../lib/CalDAV/Import/ImportEvent.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', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c167b0d053588..587f073e9d541 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -7,14 +7,14 @@ class ComposerStaticInitDAV { public static $prefixLengthsPsr4 = array ( - 'O' => + 'O' => array ( 'OCA\\DAV\\' => 8, ), ); public static $prefixDirsPsr4 = array ( - 'OCA\\DAV\\' => + 'OCA\\DAV\\' => array ( 0 => __DIR__ . '/..' . '/../lib', ), @@ -102,6 +102,10 @@ 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\\ImportEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportEvent.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', diff --git a/apps/dav/lib/CalDAV/Import/ImportCountEvent.php b/apps/dav/lib/CalDAV/Import/ImportCountEvent.php new file mode 100644 index 0000000000000..bc64c8fd15de4 --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportCountEvent.php @@ -0,0 +1,34 @@ +vevent + $this->vtodo + $this->vjournal; + } + + /** + * @return array{type: 'counts', counts: array{VEVENT: int, VTODO: int, VJOURNAL: int}} + */ + public function jsonSerialize(): array { + return [ + 'type' => 'counts', + 'vevent' => $this->vevent, + 'vtodo' => $this->vtodo, + 'vjournal' => $this->vjournal, + ]; + } +} \ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Import/ImportDisposition.php b/apps/dav/lib/CalDAV/Import/ImportDisposition.php new file mode 100644 index 0000000000000..d19ffebee008b --- /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} + */ + public function jsonSerialize(): array { + $result = [ + 'type' => 'object', + 'identifier' => $this->identifier, + 'disposition' => $this->disposition->value, + 'errors' => $this->errors, + ]; + + return $result; + } +} \ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php index 579fb4fe2ade9..11f1feb3b245e 100644 --- a/apps/dav/lib/CalDAV/Import/ImportService.php +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -34,27 +34,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'), + }; } /** @@ -64,7 +57,7 @@ public function import($source, CalendarImpl $calendar, CalendarImportOptions $o * * @return Generator<\Sabre\VObject\Component\VCalendar> */ - public function importText($source): Generator { + public function importText($source, CalendarImportOptions|null $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -86,6 +79,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 @@ -117,7 +118,7 @@ public function importText($source): Generator { * * @return Generator<\Sabre\VObject\Component\VCalendar> */ - public function importXml($source): Generator { + public function importXml($source, CalendarImportOptions|null $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -133,6 +134,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 @@ -164,7 +173,7 @@ public function importXml($source): Generator { * * @return Generator<\Sabre\VObject\Component\VCalendar> */ - public function importJson($source): Generator { + public function importJson($source, CalendarImportOptions|null $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -179,7 +188,18 @@ public function importJson($source): Generator { } } // calendar components - foreach ($importer->getBaseComponents() as $base) { + $baseComponents = $importer->getBaseComponents(); + // object counts before streaming if requested + if ($options?->getCounts()) { + $counts = ['VEVENT' => 0, 'VTODO' => 0, 'VJOURNAL' => 0]; + foreach ($baseComponents as $component) { + if (isset($counts[$component->name])) { + $counts[$component->name]++; + } + } + yield 'counts' => $counts; + } + foreach ($baseComponents as $base) { $vObject = new VCalendar; $vObject->VERSION = clone $importer->VERSION; $vObject->PRODID = clone $importer->PRODID; @@ -226,14 +246,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) { @@ -241,7 +269,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 @@ -255,7 +287,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; } } @@ -266,7 +302,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(); @@ -274,7 +314,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]); @@ -291,7 +335,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()) { @@ -300,9 +347,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) { @@ -310,11 +363,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/Command/ImportCalendar.php b/apps/dav/lib/Command/ImportCalendar.php index 1475d26fbb648..e025bc63f48dd 100644 --- a/apps/dav/lib/Command/ImportCalendar.php +++ b/apps/dav/lib/Command/ImportCalendar.php @@ -10,6 +10,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; @@ -101,80 +104,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 index 2d1cc896e5769..b05c2f88ba96d 100644 --- a/apps/dav/lib/Controller/CalendarExportController.php +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -12,7 +12,6 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; -use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\StreamGeneratorResponse; diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php index 96dd175103952..6c60de3256fc8 100644 --- a/apps/dav/lib/Controller/CalendarImportController.php +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -20,6 +20,7 @@ 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\Calendar\CalendarImportOptions; use OCP\Calendar\IManager; @@ -53,23 +54,20 @@ public function __construct( * * @return DataResponse, total: non-negative-int}, updated?: array{items: list, total: non-negative-int}, skipped?: array{items: list, total: non-negative-int}, errors?: array{items: list, total: non-negative-int}}, array{}> * - * 200: calendar data - * 400: invalid request + * 200: NDJSON stream of import event objects + * 400: invalid parameters * 401: user not authorized + * 500: internal server error during import */ #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] #[UserRateLimit(limit: 1, period: 60)] #[NoAdminRequired] - public function import(string $id, array $options, string $data, ?string $user = null): DataResponse { + public function import(string $id, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { $calendarId = $id; $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; - $showCreated = $options['showCreated'] ?? false; - $showUpdated = $options['showUpdated'] ?? false; - $showSkipped = $options['showSkipped'] ?? false; - $showErrors = $options['showErrors'] ?? false; // evaluate if user is logged in and has permissions if (!$this->userSession->isLoggedIn()) { return new DataResponse([], Http::STATUS_UNAUTHORIZED); @@ -122,73 +120,25 @@ public function import(string $id, array $options, string $data, ?string $user = } catch (InvalidArgumentException) { return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST); } + $options->setCounts(true); // process the data - $timeStarted = microtime(true); - try { - $tempPath = $this->tempManager->getTemporaryFile(); - $tempFile = fopen($tempPath, 'w+'); - fwrite($tempFile, $data); - unset($data); - fseek($tempFile, 0); - $outcome = $this->importService->import($tempFile, $calendar, $options); - } catch (\Throwable $e) { - return new DataResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); - } finally { - fclose($tempFile); - } - $timeFinished = microtime(true); - - // summarize the outcome - $objectsCreated = []; - $objectsUpdated = []; - $objectsSkipped = []; - $objectsErrors = []; - $totalCreated = 0; - $totalUpdated = 0; - $totalSkipped = 0; - $totalErrors = 0; + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + fwrite($tempFile, $data); + unset($data); + fseek($tempFile, 0); - if ($outcome !== []) { - foreach ($outcome as $id => $result) { - if (isset($result['outcome'])) { - switch ($result['outcome']) { - case 'created': - $totalCreated++; - if ($showCreated) { - $objectsCreated[] = $id; - } - break; - case 'updated': - $totalUpdated++; - if ($showUpdated) { - $objectsUpdated[] = $id; - } - break; - case 'exists': - $totalSkipped++; - if ($showSkipped) { - $objectsSkipped[] = $id; - } - break; - case 'error': - $totalErrors++; - if ($showErrors) { - $objectsErrors[] = $id; - } - break; - } + $importGenerator = $this->importService->import($tempFile, $calendar, $options); + $stream = (function () use ($importGenerator, $tempFile): \Generator { + try { + foreach ($importGenerator as $result) { + yield json_encode($result) . "\n"; } - + } finally { + fclose($tempFile); } - } - $summary = [ - 'time' => ($timeFinished - $timeStarted), - 'created' => ['total' => $totalCreated, 'items' => $objectsCreated], - 'updated' => ['total' => $totalUpdated, 'items' => $objectsUpdated], - 'skipped' => ['total' => $totalSkipped, 'items' => $objectsSkipped], - 'errors' => ['total' => $totalErrors, 'items' => $objectsErrors], - ]; + })(); - return new DataResponse($summary, Http::STATUS_OK); + return new StreamGeneratorResponse($stream, 'application/x-ndjson'); } } diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 8dd94d6d4cf25..759f412e518cd 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -13,6 +13,9 @@ 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\ImportEvent; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\App\IAppManager; use OCP\Calendar\CalendarExportOptions; @@ -331,24 +334,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 +443,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 +514,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/tests/unit/CalDAV/Import/ImportServiceTest.php b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php index 27db3ff9d8452..54f28610af6e1 100644 --- a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -9,6 +9,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; @@ -20,7 +23,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(); @@ -56,6 +58,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()) @@ -80,13 +83,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 { @@ -140,12 +148,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/public/Calendar/CalendarImportOptions.php b/lib/public/Calendar/CalendarImportOptions.php index f7322a7ed56f5..0eac676f59349 100644 --- a/lib/public/Calendar/CalendarImportOptions.php +++ b/lib/public/Calendar/CalendarImportOptions.php @@ -63,6 +63,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 @@ -155,4 +156,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; + } + }