feat: OCS Calendar Export + Import

Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
pull/55178/head
SebastianKrupinski 11 months ago
parent afb421ea33
commit 06ea3906eb
  1. 6
      apps/dav/composer/composer/autoload_classmap.php
  2. 6
      apps/dav/composer/composer/autoload_static.php
  3. 35
      apps/dav/lib/CalDAV/Import/ImportCountEvent.php
  4. 16
      apps/dav/lib/CalDAV/Import/ImportDisposition.php
  5. 14
      apps/dav/lib/CalDAV/Import/ImportEvent.php
  6. 40
      apps/dav/lib/CalDAV/Import/ImportObjectEvent.php
  7. 137
      apps/dav/lib/CalDAV/Import/ImportService.php
  8. 113
      apps/dav/lib/Command/ImportCalendar.php
  9. 107
      apps/dav/lib/Controller/CalendarExportController.php
  10. 149
      apps/dav/lib/Controller/CalendarImportController.php
  11. 56
      apps/dav/lib/UserMigration/CalendarMigrator.php
  12. 413
      apps/dav/openapi.json
  13. 25
      apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php
  14. 1
      lib/composer/composer/autoload_classmap.php
  15. 1
      lib/composer/composer/autoload_static.php
  16. 63
      lib/public/AppFramework/Http/StreamGeneratorResponse.php
  17. 19
      lib/public/Calendar/CalendarImportOptions.php
  18. 413
      openapi.json
  19. 46
      tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php

@ -89,6 +89,10 @@ return array(
'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',
@ -263,6 +267,8 @@ return array(
'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\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php',

@ -104,6 +104,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',
@ -278,6 +282,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\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php',

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Import;
final readonly class ImportCountEvent implements ImportEvent {
public function __construct(
public int $vevent,
public int $vtodo,
public int $vjournal,
) {
}
public function total(): int {
return $this->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,
];
}
}

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Import;
enum ImportDisposition: string {
case Created = 'created';
case Updated = 'updated';
case Exists = 'exists';
case Error = 'error';
}

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Import;
use JsonSerializable;
interface ImportEvent extends JsonSerializable {
}

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Import;
final readonly class ImportObjectEvent implements ImportEvent {
/**
* @param list<string> $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<string>}
*/
#[\Override]
public function jsonSerialize(): array {
$result = [
'type' => 'object',
'identifier' => $this->identifier,
'disposition' => $this->disposition->value,
'errors' => $this->errors,
];
return $result;
}
}

@ -34,27 +34,20 @@ class ImportService {
*
* @param resource $source
*
* @return array<string,array<string,string|array<string>>>
* @return Generator<int, ImportEvent>
*
* @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'),
};
}
/**
@ -62,9 +55,9 @@ class ImportService {
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
* @return Generator<int|string, VCalendar|array{VEVENT: int, VTODO: int, VJOURNAL: int}, mixed, void>
*/
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');
}
@ -86,6 +79,14 @@ class ImportService {
$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
@ -115,9 +116,9 @@ class ImportService {
*
* @param resource $source
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
* @return Generator<int|string, VCalendar|array{VEVENT: int, VTODO: int, VJOURNAL: int}, mixed, void>
*/
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');
}
@ -133,6 +134,14 @@ class ImportService {
$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
@ -161,10 +170,11 @@ class ImportService {
* Generates object stream from a json formatted source (jcal)
*
* @param resource $source
* @param CalendarImportOptions|null $options
*
* @return Generator<\Sabre\VObject\Component\VCalendar>
* @return Generator<int|string, VCalendar|array{VEVENT: int, VTODO: int, VJOURNAL: int}, mixed, void>
*/
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');
}
@ -179,7 +189,27 @@ class ImportService {
}
}
// 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;
@ -226,14 +256,22 @@ class ImportService {
* @param CalendarImportOptions $options
* @param callable $generator<CalendarImportOptions>: Generator<\Sabre\VObject\Component\VCalendar>
*
* @return array<string,array<string,string|array<string>>>
* @return Generator<int, ImportEvent>
*/
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 +279,11 @@ class ImportService {
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 +297,11 @@ class ImportService {
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 +312,11 @@ class ImportService {
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 +324,11 @@ class ImportService {
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 +345,10 @@ class ImportService {
$objectId,
$objectData
);
$outcome[$uid] = ['outcome' => 'created'];
yield new ImportObjectEvent(
disposition: ImportDisposition::Created,
identifier: $uid,
);
} else {
[$cid, $oid] = explode('/', $objectId);
if ($options->getSupersede()) {
@ -300,9 +357,15 @@ class ImportService {
$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 +373,13 @@ class ImportService {
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;
}
/**

@ -10,6 +10,9 @@ namespace OCA\DAV\Command;
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 @@ class ImportCalendar extends Command {
$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',

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Controller;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\Export\ExportService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\StreamGeneratorResponse;
use OCP\AppFramework\OCSController;
use OCP\Calendar\CalendarExportOptions;
use OCP\Calendar\ICalendarExport;
use OCP\Calendar\IManager;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
class CalendarExportController extends OCSController {
public function __construct(
IRequest $request,
private IUserSession $userSession,
private IUserManager $userManager,
private IGroupManager $groupManager,
private IManager $calendarManager,
private ExportService $exportService,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* Export calendar data
*
* @param string $target calendar id
* @param string|null $type data format
* @param array{rangeStart:string,rangeCount:positive-int} $options configuration options
* @param string|null $user system user id
*
* @return StreamGeneratorResponse<Http::STATUS_OK, array{Content-Type:'text/calendar; charset=UTF-8'|'application/calendar+json; charset=UTF-8'|'application/calendar+xml; charset=UTF-8'}> | DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED, array{error?: non-empty-string}, array{}>
*
* 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 StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType, Http::STATUS_OK);
$response->cacheFor(0);
return $response;
}
}

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Controller;
use InvalidArgumentException;
/**
* @psalm-type CalendarImportResult = array{items: list<string>, 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\StreamGeneratorResponse;
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 StreamGeneratorResponse<Http::STATUS_OK, array{Content-Type:'application/x-ndjson'}> | DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED, array{error?: non-empty-string}, array{}>
*
* 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|StreamGeneratorResponse {
$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 StreamGeneratorResponse($stream, 'application/x-ndjson');
}
}

@ -13,6 +13,8 @@ use OCA\DAV\AppInfo\Application;
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 @@ class CalendarMigrator implements IMigrator, ISizeEstimationMigrator {
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 @@ class CalendarMigrator implements IMigrator, ISizeEstimationMigrator {
$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 @@ class CalendarMigrator implements IMigrator, ISizeEstimationMigrator {
}
}
private function importSummary(string $label, array $outcome, OutputInterface $output): void {
/**
* @param iterable<ImportObjectEvent> $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++,
};
}

@ -1576,6 +1576,419 @@
}
}
}
},
"/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": {
"anyOf": []
}
},
"application/calendar+json; charset=UTF-8": {
"schema": {
"anyOf": []
}
},
"application/calendar+xml; charset=UTF-8": {
"schema": {
"anyOf": []
}
}
}
},
"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": {
"anyOf": []
}
}
}
},
"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": []

@ -9,6 +9,9 @@ namespace OCA\DAV\Tests\unit\CalDAV\Import;
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 @@ class ImportServiceTest extends \Test\TestCase {
// construct import options
$options = new CalendarImportOptions();
$options->setFormat('ical');
$options->setCounts(true);
// Mock calendar methods
$this->calendar->expects($this->once())
@ -80,13 +83,18 @@ class ImportServiceTest extends \Test\TestCase {
);
// 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 @@ class ImportServiceTest extends \Test\TestCase {
);
// 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);
}
}

@ -163,6 +163,7 @@ return array(
'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',

@ -204,6 +204,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',

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\AppFramework\Http;
use Generator;
use OCP\AppFramework\Http;
/**
* @since 35.0.0
*
* @template S of Http::STATUS_*
* @template H of array<string, mixed>
* @template-extends Response<Http::STATUS_*, array<string, mixed>>
*/
class StreamGeneratorResponse extends Response implements ICallbackResponse {
protected $generator;
/**
* @since 35.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 35.0.0
*
* @param IOutput $output a small wrapper that handles output
*/
#[\Override]
public function callback(IOutput $output): void {
foreach ($this->generator as $chunk) {
print($chunk);
flush();
}
}
}

@ -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 @@ final class CalendarImportOptions {
$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;
}
}

@ -21255,6 +21255,419 @@
}
}
},
"/ocs/v2.php/calendar/export": {
"post": {
"operationId": "dav-calendar_export-export",
"summary": "Export calendar data",
"tags": [
"dav/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": {
"anyOf": []
}
},
"application/calendar+json; charset=UTF-8": {
"schema": {
"anyOf": []
}
},
"application/calendar+xml; charset=UTF-8": {
"schema": {
"anyOf": []
}
}
}
},
"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-import",
"summary": "Import calendar data",
"tags": [
"dav/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": {
"anyOf": []
}
}
}
},
"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": {}
}
}
}
}
]
}
}
}
}
}
}
},
"/index.php/apps/federatedfilesharing/createFederatedShare": {
"post": {
"operationId": "federatedfilesharing-mount_public_link-create-federated-share",

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\AppFramework\Http;
use OCP\AppFramework\Http\StreamGeneratorResponse;
class StreamGeneratorResponseTest extends \Test\TestCase {
protected function setUp(): void {
parent::setUp();
}
public function testConstructor() {
$generator = function () {
yield 'chunk1';
yield 'chunk2';
};
$response = new StreamGeneratorResponse($generator(), '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 StreamGeneratorResponse($generator(), 'text/plain');
$output = $this->createMock(\OCP\AppFramework\Http\IOutput::class);
$response->callback($output);
$this->assertEquals($count, 2);
}
}
Loading…
Cancel
Save