Skip to content

Commit 95ef88c

Browse files
feat: OCS Calendar Export + Import
Signed-off-by: SebastianKrupinski <[email protected]>
1 parent 00ff3be commit 95ef88c

File tree

8 files changed

+1973
-0
lines changed

8 files changed

+1973
-0
lines changed

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@
255255
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
256256
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
257257
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
258+
'OCA\\DAV\\Controller\\CalendarExportController' => $baseDir . '/../lib/Controller/CalendarExportController.php',
259+
'OCA\\DAV\\Controller\\CalendarImportController' => $baseDir . '/../lib/Controller/CalendarImportController.php',
258260
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
259261
'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php',
260262
'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ class ComposerStaticInitDAV
270270
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
271271
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
272272
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
273+
'OCA\\DAV\\Controller\\CalendarExportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarExportController.php',
274+
'OCA\\DAV\\Controller\\CalendarImportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarImportController.php',
273275
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
274276
'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php',
275277
'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php',
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 $type 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: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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+
*/
57+
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
58+
#[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')]
59+
#[UserRateLimit(limit: 1, period: 60)]
60+
#[NoAdminRequired]
61+
public function index(string $id, array $options, string $data, ?string $user = null): DataResponse {
62+
$userId = $user;
63+
$calendarId = $id;
64+
$format = isset($options['format']) ? $options['format'] : null;
65+
$validation = isset($options['validation']) ? (int)$options['validation'] : null;
66+
$errors = isset($options['errors']) ? (int)$options['errors'] : null;
67+
$supersede = (bool)$options['supersede'] ?? false;
68+
$showCreated = (bool)$options['showCreated'] ?? false;
69+
$showUpdated = (bool)$options['showUpdated'] ?? false;
70+
$showSkipped = (bool)$options['showSkipped'] ?? false;
71+
$showErrors = (bool)$options['showErrors'] ?? false;
72+
// evaluate if user is logged in and has permissions
73+
if (!$this->userSession->isLoggedIn()) {
74+
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
75+
}
76+
if ($userId !== null) {
77+
if ($this->userSession->getUser()->getUID() !== $userId
78+
&& $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) {
79+
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
80+
}
81+
if (!$this->userManager->userExists($userId)) {
82+
return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST);
83+
}
84+
} else {
85+
$userId = $this->userSession->getUser()->getUID();
86+
}
87+
// retrieve calendar and evaluate if import is supported and writeable
88+
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]);
89+
if ($calendars === []) {
90+
return new DataResponse(['error' => "Calendar <$calendarId> not found"], Http::STATUS_BAD_REQUEST);
91+
}
92+
$calendar = $calendars[0];
93+
if (!$calendar instanceof CalendarImpl) {
94+
return new DataResponse(['error' => "Calendar <$calendarId> dose support this function"], Http::STATUS_BAD_REQUEST);
95+
}
96+
if (!$calendar->isWritable()) {
97+
return new DataResponse(['error' => "Calendar <$calendarId> is not writeable"], Http::STATUS_BAD_REQUEST);
98+
}
99+
if ($calendar->isDeleted()) {
100+
return new DataResponse(['error' => "Calendar <$calendarId> is deleted"], Http::STATUS_BAD_REQUEST);
101+
}
102+
// construct options object
103+
$options = new CalendarImportOptions();
104+
$options->setSupersede($supersede);
105+
if ($errors !== null) {
106+
try {
107+
$options->setErrors($errors);
108+
} catch (InvalidArgumentException) {
109+
return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST);
110+
}
111+
}
112+
if ($validation !== null) {
113+
try {
114+
$options->setValidate($validation);
115+
} catch (InvalidArgumentException) {
116+
return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST);
117+
}
118+
}
119+
try {
120+
$options->setFormat($format ?? 'ical');
121+
} catch (InvalidArgumentException) {
122+
return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST);
123+
}
124+
// process the data
125+
$timeStarted = microtime(true);
126+
try {
127+
$tempPath = $this->tempManager->getTemporaryFile();
128+
$tempFile = fopen($tempPath, 'w+');
129+
fwrite($tempFile, $data);
130+
unset($data);
131+
fseek($tempFile, 0);
132+
$outcome = $this->importService->import($tempFile, $calendar, $options);
133+
} catch (\Throwable $e) {
134+
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
135+
} finally {
136+
fclose($tempFile);
137+
}
138+
$timeFinished = microtime(true);
139+
140+
// summarize the outcome
141+
$objectsCreated = [];
142+
$objectsUpdated = [];
143+
$objectsSkipped = [];
144+
$objectsErrors = [];
145+
$totalCreated = 0;
146+
$totalUpdated = 0;
147+
$totalSkipped = 0;
148+
$totalErrors = 0;
149+
150+
if ($outcome !== []) {
151+
foreach ($outcome as $id => $result) {
152+
if (isset($result['outcome'])) {
153+
switch ($result['outcome']) {
154+
case 'created':
155+
$totalCreated++;
156+
if ($showCreated) {
157+
$objectsCreated[] = $id;
158+
}
159+
break;
160+
case 'updated':
161+
$totalUpdated++;
162+
if ($showUpdated) {
163+
$objectsUpdated[] = $id;
164+
}
165+
break;
166+
case 'exists':
167+
$totalSkipped++;
168+
if ($showSkipped) {
169+
$objectsSkipped[] = $id;
170+
}
171+
break;
172+
case 'error':
173+
$totalErrors++;
174+
if ($showErrors) {
175+
$objectsErrors[] = $id;
176+
}
177+
break;
178+
}
179+
}
180+
181+
}
182+
}
183+
$summary = [
184+
'time' => ($timeFinished - $timeStarted),
185+
'created' => ['total' => $totalCreated, 'items' => $objectsCreated],
186+
'updated' => ['total' => $totalUpdated, 'items' => $objectsUpdated],
187+
'skipped' => ['total' => $totalSkipped, 'items' => $objectsSkipped],
188+
'errors' => ['total' => $totalErrors, 'items' => $objectsErrors],
189+
];
190+
191+
return new DataResponse($summary, Http::STATUS_OK);
192+
}
193+
}

0 commit comments

Comments
 (0)