Skip to content

Commit 12ad7af

Browse files
feat: OCS Calendar Export + Import
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent 09eb205 commit 12ad7af

4 files changed

Lines changed: 411 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\DAV\Controller;
9+
10+
use OCA\DAV\AppInfo\Application;
11+
use OCA\DAV\CalDAV\Export\ExportService;
12+
use OCP\AppFramework\Http;
13+
use OCP\AppFramework\Http\Attribute\ApiRoute;
14+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
15+
use OCP\AppFramework\Http\Attribute\OpenAPI;
16+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
17+
use OCP\AppFramework\Http\DataResponse;
18+
use OCP\AppFramework\Http\StreamGeneratorResponse;
19+
use OCP\AppFramework\OCSController;
20+
use OCP\Calendar\CalendarExportOptions;
21+
use OCP\Calendar\ICalendarExport;
22+
use OCP\Calendar\IManager;
23+
use OCP\IGroupManager;
24+
use OCP\IRequest;
25+
use OCP\IUserManager;
26+
use OCP\IUserSession;
27+
28+
class CalendarExportController extends OCSController {
29+
30+
public function __construct(
31+
IRequest $request,
32+
private IUserSession $userSession,
33+
private IUserManager $userManager,
34+
private IGroupManager $groupManager,
35+
private IManager $calendarManager,
36+
private ExportService $exportService,
37+
) {
38+
parent::__construct(Application::APP_ID, $request);
39+
}
40+
41+
/**
42+
* Export calendar data
43+
*
44+
* @param string $id calendar id
45+
* @param string|null $format data format
46+
* @param array{rangeStart:string,rangeCount:int<1,max>} $options configuration options
47+
* @param string|null $user system user id
48+
*
49+
* @return StreamGeneratorResponse<Http::STATUS_OK, array{Content-Type:string}> | DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED, array{error?: non-empty-string}, array{}>
50+
*
51+
* 200: data in requested format
52+
* 400: invalid parameters
53+
* 401: user not authorized
54+
*/
55+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
56+
#[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')]
57+
#[UserRateLimit(limit: 60, period: 60)]
58+
#[NoAdminRequired]
59+
public function index(string $id, ?string $type = null, ?array $options = null, ?string $user = null) {
60+
$userId = $user;
61+
$calendarId = $id;
62+
$format = $type ?? 'ical';
63+
$rangeStart = isset($options['rangeStart']) ? (string)$options['rangeStart'] : null;
64+
$rangeCount = isset($options['rangeCount']) ? (int)$options['rangeCount'] : null;
65+
// evaluate if user is logged in and has permissions
66+
if (!$this->userSession->isLoggedIn()) {
67+
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
68+
}
69+
if ($userId !== null) {
70+
if ($this->userSession->getUser()->getUID() !== $userId &&
71+
$this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) {
72+
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
73+
}
74+
if (!$this->userManager->userExists($userId)) {
75+
return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST);
76+
}
77+
} else {
78+
$userId = $this->userSession->getUser()->getUID();
79+
}
80+
// retrieve calendar and evaluate if export is supported
81+
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]);
82+
if ($calendars === []) {
83+
return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST);
84+
}
85+
$calendar = $calendars[0];
86+
if (!$calendar instanceof ICalendarExport) {
87+
return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST);
88+
}
89+
// construct options object
90+
$options = new CalendarExportOptions();
91+
$options->setRangeStart($rangeStart);
92+
$options->setRangeCount($rangeCount);
93+
// evaluate if provided format is supported
94+
if (!in_array($format, ExportService::FORMATS, true)) {
95+
return new DataResponse(['error' => "Format <$format> is not valid."], Http::STATUS_BAD_REQUEST);
96+
}
97+
$options->setFormat($format);
98+
// construct response
99+
$contentType = match (strtolower($options->getFormat())) {
100+
'jcal' => 'application/calendar+json; charset=UTF-8',
101+
'xcal' => 'application/calendar+xml; charset=UTF-8',
102+
default => 'text/calendar; charset=UTF-8'
103+
};
104+
$response = new StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType, Http::STATUS_OK);
105+
$response->cacheFor(0);
106+
107+
return $response;
108+
}
109+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
namespace OCA\DAV\Controller;
9+
10+
use InvalidArgumentException;
11+
use OCA\DAV\AppInfo\Application;
12+
use OCA\DAV\CalDAV\CalendarImpl;
13+
use OCA\DAV\CalDAV\Import\ImportService;
14+
use OCP\AppFramework\Http;
15+
use OCP\AppFramework\Http\Attribute\ApiRoute;
16+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
17+
use OCP\AppFramework\Http\Attribute\OpenAPI;
18+
use OCP\AppFramework\Http\Attribute\UserRateLimit;
19+
use OCP\AppFramework\Http\DataResponse;
20+
use OCP\AppFramework\OCSController;
21+
use OCP\Calendar\CalendarImportOptions;
22+
use OCP\Calendar\IManager;
23+
use OCP\IGroupManager;
24+
use OCP\IRequest;
25+
use OCP\ITempManager;
26+
use OCP\IUserManager;
27+
use OCP\IUserSession;
28+
29+
class CalendarImportController extends OCSController {
30+
31+
public function __construct(
32+
IRequest $request,
33+
private IUserSession $userSession,
34+
private IUserManager $userManager,
35+
private IGroupManager $groupManager,
36+
private ITempManager $tempManager,
37+
private IManager $calendarManager,
38+
private ImportService $importService,
39+
) {
40+
parent::__construct(Application::APP_ID, $request);
41+
}
42+
43+
/**
44+
* Import calendar data
45+
*
46+
* @param string $id calendar id
47+
* @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
48+
* @param string $data calendar data
49+
* @param string|null $user system user id
50+
*
51+
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_INTERNAL_SERVER_ERROR, array{error?: string, time?: float, created?: array{items: list<string>, total: int<0,max>}, updated?: array{items: list<string>, total: int<0,max>}, skipped?: array{items: list<string>, total: int<0, max>}, errors?: array{items: list<string>, total: int<0, max>}}, array{}>
52+
*
53+
* 200: calendar data
54+
* 400: invalid request
55+
* 401: user not authorized
56+
* 404: calendar not found
57+
* 404: format not found
58+
*/
59+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
60+
#[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')]
61+
#[UserRateLimit(limit: 1, period: 60)]
62+
#[NoAdminRequired]
63+
public function index(string $id, array $options, string $data, ?string $user = null): DataResponse {
64+
$userId = $user;
65+
$calendarId = $id;
66+
$format = isset($options['format']) ? $options['format'] : null;
67+
$validation = isset($options['validation']) ? (int)$options['validation'] : null;
68+
$errors = isset($options['errors']) ? (int)$options['errors'] : null;
69+
$supersede = (bool)$options['supersede'] ?? false;
70+
$showCreated = (bool)$options['showCreated'] ?? false;
71+
$showUpdated = (bool)$options['showUpdated'] ?? false;
72+
$showSkipped = (bool)$options['showSkipped'] ?? false;
73+
$showErrors = (bool)$options['showErrors'] ?? false;
74+
// evaluate if user is logged in and has permissions
75+
if (!$this->userSession->isLoggedIn()) {
76+
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
77+
}
78+
if ($userId !== null) {
79+
if ($this->userSession->getUser()->getUID() !== $userId &&
80+
$this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) {
81+
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
82+
}
83+
if (!$this->userManager->userExists($userId)) {
84+
return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST);
85+
}
86+
} else {
87+
$userId = $this->userSession->getUser()->getUID();
88+
}
89+
// retrieve calendar and evaluate if import is supported and writeable
90+
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]);
91+
if ($calendars === []) {
92+
return new DataResponse(['error' => "Calendar <$calendarId> not found"], Http::STATUS_BAD_REQUEST);
93+
}
94+
$calendar = $calendars[0];
95+
if (!$calendar instanceof CalendarImpl) {
96+
return new DataResponse(['error' => "Calendar <$calendarId> dose support this function"], Http::STATUS_BAD_REQUEST);
97+
}
98+
if (!$calendar->isWritable()) {
99+
return new DataResponse(['error' => "Calendar <$calendarId> is not writeable"], Http::STATUS_BAD_REQUEST);
100+
}
101+
if ($calendar->isDeleted()) {
102+
return new DataResponse(['error' => "Calendar <$calendarId> is deleted"], Http::STATUS_BAD_REQUEST);
103+
}
104+
// construct options object
105+
$options = new CalendarImportOptions();
106+
$options->setSupersede($supersede);
107+
if ($errors !== null) {
108+
try {
109+
$options->setErrors($errors);
110+
} catch (InvalidArgumentException) {
111+
return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST);
112+
}
113+
}
114+
if ($validation !== null) {
115+
try {
116+
$options->setValidate($validation);
117+
} catch (InvalidArgumentException) {
118+
return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST);
119+
}
120+
}
121+
try {
122+
$options->setFormat($format ?? 'ical');
123+
} catch (InvalidArgumentException) {
124+
return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST);
125+
}
126+
// process the data
127+
$timeStarted = microtime(true);
128+
try {
129+
$tempPath = $this->tempManager->getTemporaryFile();
130+
$tempFile = fopen($tempPath, 'w+');
131+
fwrite($tempFile, $data);
132+
unset($data);
133+
fseek($tempFile, 0);
134+
$outcome = $this->importService->import($tempFile, $calendar, $options);
135+
} catch (\Throwable $e) {
136+
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
137+
} finally {
138+
fclose($tempFile);
139+
}
140+
$timeFinished = microtime(true);
141+
142+
// summarize the outcome
143+
$objectsCreated = [];
144+
$objectsUpdated = [];
145+
$objectsSkipped = [];
146+
$objectsErrors = [];
147+
$totalCreated = 0;
148+
$totalUpdated = 0;
149+
$totalSkipped = 0;
150+
$totalErrors = 0;
151+
152+
if ($outcome !== []) {
153+
foreach ($outcome as $id => $result) {
154+
if (isset($result['outcome'])) {
155+
switch ($result['outcome']) {
156+
case 'created':
157+
$totalCreated++;
158+
if ($showCreated) {
159+
$objectsCreated[] = $id;
160+
}
161+
break;
162+
case 'updated':
163+
$totalUpdated++;
164+
if ($showUpdated) {
165+
$objectsUpdated[] = $id;
166+
}
167+
break;
168+
case 'exists':
169+
$totalSkipped++;
170+
if ($showSkipped) {
171+
$objectsSkipped[] = $id;
172+
}
173+
break;
174+
case 'error':
175+
$totalErrors++;
176+
if ($showErrors) {
177+
$objectsErrors[] = $id;
178+
}
179+
break;
180+
}
181+
}
182+
183+
}
184+
}
185+
$summary = [
186+
'time' => ($timeFinished - $timeStarted),
187+
'created' => ['total' => $totalCreated, 'items' => $objectsCreated],
188+
'updated' => ['total' => $totalUpdated, 'items' => $objectsUpdated],
189+
'skipped' => ['total' => $totalSkipped, 'items' => $objectsSkipped],
190+
'errors' => ['total' => $totalErrors, 'items' => $objectsErrors],
191+
];
192+
193+
return new DataResponse($summary, Http::STATUS_OK);
194+
}
195+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-only
8+
*/
9+
namespace OCP\AppFramework\Http;
10+
11+
use Generator;
12+
use OCP\AppFramework\Http;
13+
14+
/**
15+
* @since 32.0.0
16+
*
17+
* @template S of Http::STATUS_*
18+
* @template H of array<string, mixed>
19+
* @template-extends Response<Http::STATUS_*, array<string, mixed>>
20+
*/
21+
class StreamGeneratorResponse extends Response implements ICallbackResponse {
22+
protected $generator;
23+
24+
/**
25+
* @since 32.0.0
26+
*
27+
* @param Generator $generator the function to call to generate the response
28+
* @param string $contentType http response content type e.g. 'application/json; charset=UTF-8'
29+
* @param S $status http response status
30+
* @param array|null $headers additional headers
31+
*/
32+
public function __construct(Generator $generator, string $contentType, int $status = Http::STATUS_OK, ?array $headers = []) {
33+
parent::__construct();
34+
35+
$this->generator = $generator;
36+
37+
$this->setStatus($status);
38+
$this->addHeader('Content-Type', $contentType);
39+
40+
foreach ($headers as $key => $value) {
41+
$this->addHeader($key, $value);
42+
}
43+
}
44+
45+
/**
46+
* Streams content directly to client
47+
*
48+
* @since 32.0.0
49+
*
50+
* @param IOutput $output a small wrapper that handles output
51+
*/
52+
public function callback(IOutput $output) {
53+
54+
foreach ($this->generator as $chunk) {
55+
print($chunk);
56+
flush();
57+
}
58+
59+
}
60+
61+
}

0 commit comments

Comments
 (0)