Skip to content

Commit f5035db

Browse files
fix: Calendar Import
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent d63cbb9 commit f5035db

18 files changed

Lines changed: 1522 additions & 127 deletions

‎apps/dav/composer/composer/autoload_classmap.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@
8989
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
9090
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
9191
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
92+
'OCA\\DAV\\CalDAV\\Import\\ImportCountEvent' => $baseDir . '/../lib/CalDAV/Import/ImportCountEvent.php',
93+
'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => $baseDir . '/../lib/CalDAV/Import/ImportDisposition.php',
94+
'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => $baseDir . '/../lib/CalDAV/Import/ImportObjectEvent.php',
9295
'OCA\\DAV\\CalDAV\\Import\\ImportService' => $baseDir . '/../lib/CalDAV/Import/ImportService.php',
9396
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => $baseDir . '/../lib/CalDAV/Import/TextImporter.php',
9497
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php',
@@ -263,6 +266,8 @@
263266
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
264267
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
265268
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
269+
'OCA\\DAV\\Controller\\CalendarExportController' => $baseDir . '/../lib/Controller/CalendarExportController.php',
270+
'OCA\\DAV\\Controller\\CalendarImportController' => $baseDir . '/../lib/Controller/CalendarImportController.php',
266271
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
267272
'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php',
268273
'OCA\\DAV\\Controller\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php',

‎apps/dav/composer/composer/autoload_static.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ class ComposerStaticInitDAV
104104
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
105105
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
106106
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
107+
'OCA\\DAV\\CalDAV\\Import\\ImportCountEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportCountEvent.php',
108+
'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportDisposition.php',
109+
'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportObjectEvent.php',
107110
'OCA\\DAV\\CalDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportService.php',
108111
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/TextImporter.php',
109112
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php',
@@ -278,6 +281,8 @@ class ComposerStaticInitDAV
278281
'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php',
279282
'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php',
280283
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
284+
'OCA\\DAV\\Controller\\CalendarExportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarExportController.php',
285+
'OCA\\DAV\\Controller\\CalendarImportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarImportController.php',
281286
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
282287
'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php',
283288
'OCA\\DAV\\Controller\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php',
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\DAV\CalDAV\Import;
10+
11+
use JsonSerializable;
12+
13+
final readonly class ImportCountEvent implements JsonSerializable {
14+
public function __construct(
15+
public int $vevent,
16+
public int $vtodo,
17+
public int $vjournal,
18+
) {
19+
}
20+
21+
public function total(): int {
22+
return $this->vevent + $this->vtodo + $this->vjournal;
23+
}
24+
25+
/**
26+
* @return array{type: 'count', vevent: int, vtodo: int, vjournal: int}
27+
*/
28+
#[\Override]
29+
public function jsonSerialize(): array {
30+
return [
31+
'type' => 'count',
32+
'vevent' => $this->vevent,
33+
'vtodo' => $this->vtodo,
34+
'vjournal' => $this->vjournal,
35+
];
36+
}
37+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\DAV\CalDAV\Import;
10+
11+
enum ImportDisposition: string {
12+
case Created = 'created';
13+
case Updated = 'updated';
14+
case Exists = 'exists';
15+
case Error = 'error';
16+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\DAV\CalDAV\Import;
10+
11+
use JsonSerializable;
12+
13+
final readonly class ImportObjectEvent implements JsonSerializable {
14+
/**
15+
* @param list<string> $errors
16+
*/
17+
public function __construct(
18+
public ?string $identifier,
19+
public ImportDisposition $disposition,
20+
public array $errors = [],
21+
) {
22+
}
23+
24+
public function isError(): bool {
25+
return $this->disposition === ImportDisposition::Error;
26+
}
27+
28+
/**
29+
* @return array{type: 'object', identifier: ?string, disposition: string, errors: list<string>}
30+
*/
31+
#[\Override]
32+
public function jsonSerialize(): array {
33+
$result = [
34+
'type' => 'object',
35+
'identifier' => $this->identifier,
36+
'disposition' => $this->disposition->value,
37+
'errors' => $this->errors,
38+
];
39+
40+
return $result;
41+
}
42+
}

‎apps/dav/lib/CalDAV/Import/ImportService.php‎

Lines changed: 101 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -33,37 +33,30 @@ public function __construct(
3333
*
3434
* @param resource $source
3535
*
36-
* @return array<string,array<string,string|array<string>>>
36+
* @return Generator<int, ImportCountEvent|ImportObjectEvent>
3737
*
3838
* @throws \InvalidArgumentException
3939
*/
40-
public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): array {
40+
public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): Generator {
4141
if (!is_resource($source)) {
4242
throw new InvalidArgumentException('Invalid import source must be a file resource');
4343
}
44-
switch ($options->getFormat()) {
45-
case 'ical':
46-
return $this->importProcess($source, $calendar, $options, $this->importText(...));
47-
break;
48-
case 'jcal':
49-
return $this->importProcess($source, $calendar, $options, $this->importJson(...));
50-
break;
51-
case 'xcal':
52-
return $this->importProcess($source, $calendar, $options, $this->importXml(...));
53-
break;
54-
default:
55-
throw new InvalidArgumentException('Invalid import format');
56-
}
44+
return match ($options->getFormat()) {
45+
'ical' => $this->importProcess($source, $calendar, $options, $this->importText(...)),
46+
'jcal' => $this->importProcess($source, $calendar, $options, $this->importJson(...)),
47+
'xcal' => $this->importProcess($source, $calendar, $options, $this->importXml(...)),
48+
default => throw new InvalidArgumentException('Invalid import format'),
49+
};
5750
}
5851

5952
/**
6053
* Generates object stream from a text formatted source (ical)
6154
*
6255
* @param resource $source
6356
*
64-
* @return Generator<\Sabre\VObject\Component\VCalendar>
57+
* @return Generator<int|string, VCalendar|array{VEVENT: int, VTODO: int, VJOURNAL: int}, mixed, void>
6558
*/
66-
public function importText($source): Generator {
59+
public function importText($source, ?CalendarImportOptions $options = null): Generator {
6760
if (!is_resource($source)) {
6861
throw new InvalidArgumentException('Invalid import source must be a file resource');
6962
}
@@ -85,6 +78,14 @@ public function importText($source): Generator {
8578
$vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix);
8679
$timezones[$tid] = clone $vObject->VTIMEZONE;
8780
}
81+
// object counts before streaming if requested
82+
if ($options?->getCounts()) {
83+
yield 'counts' => [
84+
'VEVENT' => count($structure['VEVENT']),
85+
'VTODO' => count($structure['VTODO']),
86+
'VJOURNAL' => count($structure['VJOURNAL']),
87+
];
88+
}
8889
// calendar components
8990
// for each component type, construct a full calendar object with all components
9091
// that match the same UID and appropriate time zones that are used in the components
@@ -114,9 +115,9 @@ public function importText($source): Generator {
114115
*
115116
* @param resource $source
116117
*
117-
* @return Generator<\Sabre\VObject\Component\VCalendar>
118+
* @return Generator<int|string, VCalendar|array{VEVENT: int, VTODO: int, VJOURNAL: int}, mixed, void>
118119
*/
119-
public function importXml($source): Generator {
120+
public function importXml($source, ?CalendarImportOptions $options = null): Generator {
120121
if (!is_resource($source)) {
121122
throw new InvalidArgumentException('Invalid import source must be a file resource');
122123
}
@@ -132,6 +133,14 @@ public function importXml($source): Generator {
132133
$vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix);
133134
$timezones[$tid] = clone $vObject->VTIMEZONE;
134135
}
136+
// object counts before streaming if requested
137+
if ($options?->getCounts()) {
138+
yield 'counts' => [
139+
'VEVENT' => count($structure['VEVENT']),
140+
'VTODO' => count($structure['VTODO']),
141+
'VJOURNAL' => count($structure['VJOURNAL']),
142+
];
143+
}
135144
// calendar components
136145
// for each component type, construct a full calendar object with all components
137146
// that match the same UID and appropriate time zones that are used in the components
@@ -160,10 +169,11 @@ public function importXml($source): Generator {
160169
* Generates object stream from a json formatted source (jcal)
161170
*
162171
* @param resource $source
172+
* @param CalendarImportOptions|null $options
163173
*
164-
* @return Generator<\Sabre\VObject\Component\VCalendar>
174+
* @return Generator<int|string, VCalendar|array{VEVENT: int, VTODO: int, VJOURNAL: int}, mixed, void>
165175
*/
166-
public function importJson($source): Generator {
176+
public function importJson($source, ?CalendarImportOptions $options = null): Generator {
167177
if (!is_resource($source)) {
168178
throw new InvalidArgumentException('Invalid import source must be a file resource');
169179
}
@@ -178,7 +188,27 @@ public function importJson($source): Generator {
178188
}
179189
}
180190
// calendar components
181-
foreach ($importer->getBaseComponents() as $base) {
191+
$baseComponents = $importer->getBaseComponents();
192+
// object counts before streaming if requested
193+
if ($options?->getCounts()) {
194+
/** @var array{VEVENT: int, VTODO: int, VJOURNAL: int} $counts */
195+
$counts = ['VEVENT' => 0, 'VTODO' => 0, 'VJOURNAL' => 0];
196+
foreach ($baseComponents as $component) {
197+
switch ($component->name) {
198+
case 'VEVENT':
199+
$counts['VEVENT']++;
200+
break;
201+
case 'VTODO':
202+
$counts['VTODO']++;
203+
break;
204+
case 'VJOURNAL':
205+
$counts['VJOURNAL']++;
206+
break;
207+
}
208+
}
209+
yield 'counts' => $counts;
210+
}
211+
foreach ($baseComponents as $base) {
182212
$vObject = new VCalendar;
183213
$vObject->VERSION = clone $importer->VERSION;
184214
$vObject->PRODID = clone $importer->PRODID;
@@ -225,22 +255,34 @@ private function findTimeZones(VCalendar $vObject): array {
225255
* @param CalendarImportOptions $options
226256
* @param callable $generator<CalendarImportOptions>: Generator<\Sabre\VObject\Component\VCalendar>
227257
*
228-
* @return array<string,array<string,string|array<string>>>
258+
* @return Generator<int, ImportCountEvent|ImportObjectEvent>
229259
*/
230-
public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array {
260+
public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): Generator {
231261
$calendarId = $calendar->getKey();
232262
$calendarUri = $calendar->getUri();
233263
$principalUri = $calendar->getPrincipalUri();
234-
$outcome = [];
235-
foreach ($generator($source) as $vObject) {
264+
foreach ($generator($source, $options) as $key => $value) {
265+
if ($key === 'counts') {
266+
yield new ImportCountEvent(
267+
vevent: $value['VEVENT'] ?? 0,
268+
vtodo: $value['VTODO'] ?? 0,
269+
vjournal: $value['VJOURNAL'] ?? 0,
270+
);
271+
continue;
272+
}
273+
$vObject = $value;
236274
$components = $vObject->getBaseComponents();
237275
// determine if the object has no base component types
238276
if (count($components) === 0) {
239277
$errorMessage = 'One or more objects discovered with no base component types';
240278
if ($options->getErrors() === $options::ERROR_FAIL) {
241279
throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage);
242280
}
243-
$outcome['nbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]];
281+
yield new ImportObjectEvent(
282+
disposition: ImportDisposition::Error,
283+
identifier: null,
284+
errors: [$errorMessage]
285+
);
244286
continue;
245287
}
246288
// determine if the object has more than one base component type
@@ -254,7 +296,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
254296
if ($options->getErrors() === $options::ERROR_FAIL) {
255297
throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage);
256298
}
257-
$outcome['mbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]];
299+
yield new ImportObjectEvent(
300+
disposition: ImportDisposition::Error,
301+
identifier: null,
302+
errors: [$errorMessage]
303+
);
258304
continue 2;
259305
}
260306
}
@@ -265,15 +311,23 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
265311
if ($options->getErrors() === $options::ERROR_FAIL) {
266312
throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage);
267313
}
268-
$outcome['noid'] = ['outcome' => 'error', 'errors' => [$errorMessage]];
314+
yield new ImportObjectEvent(
315+
disposition: ImportDisposition::Error,
316+
identifier: null,
317+
errors: [$errorMessage]
318+
);
269319
continue;
270320
}
271321
$uid = $components[0]->UID->getValue();
272322
// validate object
273323
if ($options->getValidate() !== $options::VALIDATE_NONE) {
274324
$issues = $this->componentValidate($vObject, true, 3);
275325
if ($options->getValidate() === $options::VALIDATE_SKIP && $issues !== []) {
276-
$outcome[$uid] = ['outcome' => 'error', 'errors' => $issues];
326+
yield new ImportObjectEvent(
327+
disposition: ImportDisposition::Error,
328+
identifier: $uid,
329+
errors: $issues
330+
);
277331
continue;
278332
} elseif ($options->getValidate() === $options::VALIDATE_FAIL && $issues !== []) {
279333
throw new InvalidArgumentException('Error importing calendar data: UID <' . $uid . '> - ' . $issues[0]);
@@ -290,7 +344,10 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
290344
$objectId,
291345
$objectData
292346
);
293-
$outcome[$uid] = ['outcome' => 'created'];
347+
yield new ImportObjectEvent(
348+
disposition: ImportDisposition::Created,
349+
identifier: $uid,
350+
);
294351
} else {
295352
[$cid, $oid] = explode('/', $objectId);
296353
if ($options->getSupersede()) {
@@ -299,21 +356,29 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
299356
$oid,
300357
$objectData
301358
);
302-
$outcome[$uid] = ['outcome' => 'updated'];
359+
yield new ImportObjectEvent(
360+
disposition: ImportDisposition::Updated,
361+
identifier: $uid,
362+
);
303363
} else {
304-
$outcome[$uid] = ['outcome' => 'exists'];
364+
yield new ImportObjectEvent(
365+
disposition: ImportDisposition::Exists,
366+
identifier: $uid,
367+
);
305368
}
306369
}
307370
} catch (Exception $e) {
308371
$errorMessage = $e->getMessage();
309372
if ($options->getErrors() === $options::ERROR_FAIL) {
310373
throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e);
311374
}
312-
$outcome[$uid] = ['outcome' => 'error', 'errors' => [$errorMessage]];
375+
yield new ImportObjectEvent(
376+
disposition: ImportDisposition::Error,
377+
identifier: $uid,
378+
errors: [$errorMessage]
379+
);
313380
}
314381
}
315-
316-
return $outcome;
317382
}
318383

319384
/**

0 commit comments

Comments
 (0)