Skip to content

Commit 45970d0

Browse files
refactor: import to stream progess for user feed back
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent fe2c942 commit 45970d0

13 files changed

Lines changed: 351 additions & 200 deletions

File tree

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@
8787
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
8888
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
8989
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
90+
'OCA\\DAV\\CalDAV\\Import\\ImportCountEvent' => $baseDir . '/../lib/CalDAV/Import/ImportCountEvent.php',
91+
'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => $baseDir . '/../lib/CalDAV/Import/ImportDisposition.php',
92+
'OCA\\DAV\\CalDAV\\Import\\ImportEvent' => $baseDir . '/../lib/CalDAV/Import/ImportEvent.php',
93+
'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => $baseDir . '/../lib/CalDAV/Import/ImportObjectEvent.php',
9094
'OCA\\DAV\\CalDAV\\Import\\ImportService' => $baseDir . '/../lib/CalDAV/Import/ImportService.php',
9195
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => $baseDir . '/../lib/CalDAV/Import/TextImporter.php',
9296
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
class ComposerStaticInitDAV
88
{
99
public static $prefixLengthsPsr4 = array (
10-
'O' =>
10+
'O' =>
1111
array (
1212
'OCA\\DAV\\' => 8,
1313
),
1414
);
1515

1616
public static $prefixDirsPsr4 = array (
17-
'OCA\\DAV\\' =>
17+
'OCA\\DAV\\' =>
1818
array (
1919
0 => __DIR__ . '/..' . '/../lib',
2020
),
@@ -102,6 +102,10 @@ class ComposerStaticInitDAV
102102
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
103103
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
104104
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
105+
'OCA\\DAV\\CalDAV\\Import\\ImportCountEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportCountEvent.php',
106+
'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportDisposition.php',
107+
'OCA\\DAV\\CalDAV\\Import\\ImportEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportEvent.php',
108+
'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportObjectEvent.php',
105109
'OCA\\DAV\\CalDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportService.php',
106110
'OCA\\DAV\\CalDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/TextImporter.php',
107111
'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php',
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
final readonly class ImportCountEvent implements ImportEvent {
12+
public function __construct(
13+
public int $vevent,
14+
public int $vtodo,
15+
public int $vjournal,
16+
) {
17+
}
18+
19+
public function total(): int {
20+
return $this->vevent + $this->vtodo + $this->vjournal;
21+
}
22+
23+
/**
24+
* @return array{type: 'counts', counts: array{VEVENT: int, VTODO: int, VJOURNAL: int}}
25+
*/
26+
public function jsonSerialize(): array {
27+
return [
28+
'type' => 'count',
29+
'vevent' => $this->vevent,
30+
'vtodo' => $this->vtodo,
31+
'vjournal' => $this->vjournal,
32+
];
33+
}
34+
}
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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+
interface ImportEvent extends JsonSerializable {
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
final readonly class ImportObjectEvent implements ImportEvent {
12+
/**
13+
* @param list<string> $errors
14+
*/
15+
public function __construct(
16+
public ?string $identifier,
17+
public ImportDisposition $disposition,
18+
public array $errors = [],
19+
) {
20+
}
21+
22+
public function isError(): bool {
23+
return $this->disposition === ImportDisposition::Error;
24+
}
25+
26+
/**
27+
* @return array{type: 'object', identifier: ?string, disposition: string, errors: list<string>}
28+
*/
29+
public function jsonSerialize(): array {
30+
$result = [
31+
'type' => 'object',
32+
'identifier' => $this->identifier,
33+
'disposition' => $this->disposition->value,
34+
'errors' => $this->errors,
35+
];
36+
37+
return $result;
38+
}
39+
}

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

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

6053
/**
@@ -64,7 +57,7 @@ public function import($source, CalendarImpl $calendar, CalendarImportOptions $o
6457
*
6558
* @return Generator<\Sabre\VObject\Component\VCalendar>
6659
*/
67-
public function importText($source): Generator {
60+
public function importText($source, CalendarImportOptions|null $options = null): Generator {
6861
if (!is_resource($source)) {
6962
throw new InvalidArgumentException('Invalid import source must be a file resource');
7063
}
@@ -86,6 +79,14 @@ public function importText($source): Generator {
8679
$vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix);
8780
$timezones[$tid] = clone $vObject->VTIMEZONE;
8881
}
82+
// object counts before streaming if requested
83+
if ($options?->getCounts()) {
84+
yield 'counts' => [
85+
'VEVENT' => count($structure['VEVENT']),
86+
'VTODO' => count($structure['VTODO']),
87+
'VJOURNAL' => count($structure['VJOURNAL']),
88+
];
89+
}
8990
// calendar components
9091
// for each component type, construct a full calendar object with all components
9192
// that match the same UID and appropriate time zones that are used in the components
@@ -117,7 +118,7 @@ public function importText($source): Generator {
117118
*
118119
* @return Generator<\Sabre\VObject\Component\VCalendar>
119120
*/
120-
public function importXml($source): Generator {
121+
public function importXml($source, CalendarImportOptions|null $options = null): Generator {
121122
if (!is_resource($source)) {
122123
throw new InvalidArgumentException('Invalid import source must be a file resource');
123124
}
@@ -133,6 +134,14 @@ public function importXml($source): Generator {
133134
$vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix);
134135
$timezones[$tid] = clone $vObject->VTIMEZONE;
135136
}
137+
// object counts before streaming if requested
138+
if ($options?->getCounts()) {
139+
yield 'counts' => [
140+
'VEVENT' => count($structure['VEVENT']),
141+
'VTODO' => count($structure['VTODO']),
142+
'VJOURNAL' => count($structure['VJOURNAL']),
143+
];
144+
}
136145
// calendar components
137146
// for each component type, construct a full calendar object with all components
138147
// that match the same UID and appropriate time zones that are used in the components
@@ -164,7 +173,7 @@ public function importXml($source): Generator {
164173
*
165174
* @return Generator<\Sabre\VObject\Component\VCalendar>
166175
*/
167-
public function importJson($source): Generator {
176+
public function importJson($source, CalendarImportOptions|null $options = null): Generator {
168177
if (!is_resource($source)) {
169178
throw new InvalidArgumentException('Invalid import source must be a file resource');
170179
}
@@ -179,7 +188,18 @@ public function importJson($source): Generator {
179188
}
180189
}
181190
// calendar components
182-
foreach ($importer->getBaseComponents() as $base) {
191+
$baseComponents = $importer->getBaseComponents();
192+
// object counts before streaming if requested
193+
if ($options?->getCounts()) {
194+
$counts = ['VEVENT' => 0, 'VTODO' => 0, 'VJOURNAL' => 0];
195+
foreach ($baseComponents as $component) {
196+
if (isset($counts[$component->name])) {
197+
$counts[$component->name]++;
198+
}
199+
}
200+
yield 'counts' => $counts;
201+
}
202+
foreach ($baseComponents as $base) {
183203
$vObject = new VCalendar;
184204
$vObject->VERSION = clone $importer->VERSION;
185205
$vObject->PRODID = clone $importer->PRODID;
@@ -226,22 +246,34 @@ private function findTimeZones(VCalendar $vObject): array {
226246
* @param CalendarImportOptions $options
227247
* @param callable $generator<CalendarImportOptions>: Generator<\Sabre\VObject\Component\VCalendar>
228248
*
229-
* @return array<string,array<string,string|array<string>>>
249+
* @return Generator<int, ImportEvent>
230250
*/
231-
public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array {
251+
public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): Generator {
232252
$calendarId = $calendar->getKey();
233253
$calendarUri = $calendar->getUri();
234254
$principalUri = $calendar->getPrincipalUri();
235-
$outcome = [];
236-
foreach ($generator($source) as $vObject) {
255+
foreach ($generator($source, $options) as $key => $value) {
256+
if ($key === 'counts') {
257+
yield new ImportCountEvent(
258+
vevent: $value['VEVENT'] ?? 0,
259+
vtodo: $value['VTODO'] ?? 0,
260+
vjournal: $value['VJOURNAL'] ?? 0,
261+
);
262+
continue;
263+
}
264+
$vObject = $value;
237265
$components = $vObject->getBaseComponents();
238266
// determine if the object has no base component types
239267
if (count($components) === 0) {
240268
$errorMessage = 'One or more objects discovered with no base component types';
241269
if ($options->getErrors() === $options::ERROR_FAIL) {
242270
throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage);
243271
}
244-
$outcome['nbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]];
272+
yield new ImportObjectEvent(
273+
disposition: ImportDisposition::Error,
274+
identifier: null,
275+
errors: [$errorMessage]
276+
);
245277
continue;
246278
}
247279
// determine if the object has more than one base component type
@@ -255,7 +287,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
255287
if ($options->getErrors() === $options::ERROR_FAIL) {
256288
throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage);
257289
}
258-
$outcome['mbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]];
290+
yield new ImportObjectEvent(
291+
disposition: ImportDisposition::Error,
292+
identifier: null,
293+
errors: [$errorMessage]
294+
);
259295
continue 2;
260296
}
261297
}
@@ -266,15 +302,23 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
266302
if ($options->getErrors() === $options::ERROR_FAIL) {
267303
throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage);
268304
}
269-
$outcome['noid'] = ['outcome' => 'error', 'errors' => [$errorMessage]];
305+
yield new ImportObjectEvent(
306+
disposition: ImportDisposition::Error,
307+
identifier: null,
308+
errors: [$errorMessage]
309+
);
270310
continue;
271311
}
272312
$uid = $components[0]->UID->getValue();
273313
// validate object
274314
if ($options->getValidate() !== $options::VALIDATE_NONE) {
275315
$issues = $this->componentValidate($vObject, true, 3);
276316
if ($options->getValidate() === $options::VALIDATE_SKIP && $issues !== []) {
277-
$outcome[$uid] = ['outcome' => 'error', 'errors' => $issues];
317+
yield new ImportObjectEvent(
318+
disposition: ImportDisposition::Error,
319+
identifier: $uid,
320+
errors: $issues
321+
);
278322
continue;
279323
} elseif ($options->getValidate() === $options::VALIDATE_FAIL && $issues !== []) {
280324
throw new InvalidArgumentException('Error importing calendar data: UID <' . $uid . '> - ' . $issues[0]);
@@ -291,7 +335,10 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
291335
$objectId,
292336
$objectData
293337
);
294-
$outcome[$uid] = ['outcome' => 'created'];
338+
yield new ImportObjectEvent(
339+
disposition: ImportDisposition::Created,
340+
identifier: $uid,
341+
);
295342
} else {
296343
[$cid, $oid] = explode('/', $objectId);
297344
if ($options->getSupersede()) {
@@ -300,21 +347,29 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt
300347
$oid,
301348
$objectData
302349
);
303-
$outcome[$uid] = ['outcome' => 'updated'];
350+
yield new ImportObjectEvent(
351+
disposition: ImportDisposition::Updated,
352+
identifier: $uid,
353+
);
304354
} else {
305-
$outcome[$uid] = ['outcome' => 'exists'];
355+
yield new ImportObjectEvent(
356+
disposition: ImportDisposition::Exists,
357+
identifier: $uid,
358+
);
306359
}
307360
}
308361
} catch (Exception $e) {
309362
$errorMessage = $e->getMessage();
310363
if ($options->getErrors() === $options::ERROR_FAIL) {
311364
throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e);
312365
}
313-
$outcome[$uid] = ['outcome' => 'error', 'errors' => [$errorMessage]];
366+
yield new ImportObjectEvent(
367+
disposition: ImportDisposition::Error,
368+
identifier: $uid,
369+
errors: [$errorMessage]
370+
);
314371
}
315372
}
316-
317-
return $outcome;
318373
}
319374

320375
/**

0 commit comments

Comments
 (0)