Skip to content

Commit 31899d9

Browse files
authored
Merge pull request #51924 from nextcloud/feat/issue-563-calendar-export
feat: Calendar Export
2 parents 5cf799b + a2d4f8d commit 31899d9

File tree

15 files changed

+522
-15
lines changed

15 files changed

+522
-15
lines changed

apps/dav/appinfo/info.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
<command>OCA\DAV\Command\CreateSubscription</command>
6161
<command>OCA\DAV\Command\DeleteCalendar</command>
6262
<command>OCA\DAV\Command\DeleteSubscription</command>
63+
<command>OCA\DAV\Command\ExportCalendar</command>
6364
<command>OCA\DAV\Command\FixCalendarSyncCommand</command>
6465
<command>OCA\DAV\Command\ListAddressbooks</command>
6566
<command>OCA\DAV\Command\ListCalendars</command>

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
'OCA\\DAV\\CalDAV\\EventReader' => $baseDir . '/../lib/CalDAV/EventReader.php',
6565
'OCA\\DAV\\CalDAV\\EventReaderRDate' => $baseDir . '/../lib/CalDAV/EventReaderRDate.php',
6666
'OCA\\DAV\\CalDAV\\EventReaderRRule' => $baseDir . '/../lib/CalDAV/EventReaderRRule.php',
67+
'OCA\\DAV\\CalDAV\\Export\\ExportService' => $baseDir . '/../lib/CalDAV/Export/ExportService.php',
6768
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
6869
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
6970
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
@@ -159,6 +160,7 @@
159160
'OCA\\DAV\\Command\\CreateSubscription' => $baseDir . '/../lib/Command/CreateSubscription.php',
160161
'OCA\\DAV\\Command\\DeleteCalendar' => $baseDir . '/../lib/Command/DeleteCalendar.php',
161162
'OCA\\DAV\\Command\\DeleteSubscription' => $baseDir . '/../lib/Command/DeleteSubscription.php',
163+
'OCA\\DAV\\Command\\ExportCalendar' => $baseDir . '/../lib/Command/ExportCalendar.php',
162164
'OCA\\DAV\\Command\\FixCalendarSyncCommand' => $baseDir . '/../lib/Command/FixCalendarSyncCommand.php',
163165
'OCA\\DAV\\Command\\ListAddressbooks' => $baseDir . '/../lib/Command/ListAddressbooks.php',
164166
'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class ComposerStaticInitDAV
7979
'OCA\\DAV\\CalDAV\\EventReader' => __DIR__ . '/..' . '/../lib/CalDAV/EventReader.php',
8080
'OCA\\DAV\\CalDAV\\EventReaderRDate' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRDate.php',
8181
'OCA\\DAV\\CalDAV\\EventReaderRRule' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRRule.php',
82+
'OCA\\DAV\\CalDAV\\Export\\ExportService' => __DIR__ . '/..' . '/../lib/CalDAV/Export/ExportService.php',
8283
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
8384
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
8485
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
@@ -174,6 +175,7 @@ class ComposerStaticInitDAV
174175
'OCA\\DAV\\Command\\CreateSubscription' => __DIR__ . '/..' . '/../lib/Command/CreateSubscription.php',
175176
'OCA\\DAV\\Command\\DeleteCalendar' => __DIR__ . '/..' . '/../lib/Command/DeleteCalendar.php',
176177
'OCA\\DAV\\Command\\DeleteSubscription' => __DIR__ . '/..' . '/../lib/Command/DeleteSubscription.php',
178+
'OCA\\DAV\\Command\\ExportCalendar' => __DIR__ . '/..' . '/../lib/Command/ExportCalendar.php',
177179
'OCA\\DAV\\Command\\FixCalendarSyncCommand' => __DIR__ . '/..' . '/../lib/Command/FixCalendarSyncCommand.php',
178180
'OCA\\DAV\\Command\\ListAddressbooks' => __DIR__ . '/..' . '/../lib/Command/ListAddressbooks.php',
179181
'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php',

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use DateTime;
1010
use DateTimeImmutable;
1111
use DateTimeInterface;
12+
use Generator;
1213
use OCA\DAV\AppInfo\Application;
1314
use OCA\DAV\CalDAV\Sharing\Backend;
1415
use OCA\DAV\Connector\Sabre\Principal;
@@ -28,6 +29,7 @@
2829
use OCA\DAV\Events\SubscriptionDeletedEvent;
2930
use OCA\DAV\Events\SubscriptionUpdatedEvent;
3031
use OCP\AppFramework\Db\TTransactional;
32+
use OCP\Calendar\CalendarExportOptions;
3133
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
3234
use OCP\Calendar\Events\CalendarObjectDeletedEvent;
3335
use OCP\Calendar\Events\CalendarObjectMovedEvent;
@@ -987,6 +989,44 @@ public function restoreCalendar(int $id): void {
987989
}, $this->db);
988990
}
989991

992+
/**
993+
* Returns all calendar entries as a stream of data
994+
*
995+
* @since 32.0.0
996+
*
997+
* @return Generator<array>
998+
*/
999+
public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator {
1000+
// extract options
1001+
$rangeStart = $options?->getRangeStart();
1002+
$rangeCount = $options?->getRangeCount();
1003+
// construct query
1004+
$qb = $this->db->getQueryBuilder();
1005+
$qb->select('*')
1006+
->from('calendarobjects')
1007+
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
1008+
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
1009+
->andWhere($qb->expr()->isNull('deleted_at'));
1010+
if ($rangeStart !== null) {
1011+
$qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart)));
1012+
}
1013+
if ($rangeCount !== null) {
1014+
$qb->setMaxResults($rangeCount);
1015+
}
1016+
if ($rangeStart !== null || $rangeCount !== null) {
1017+
$qb->orderBy('uid', 'ASC');
1018+
}
1019+
$rs = $qb->executeQuery();
1020+
// iterate through results
1021+
try {
1022+
while (($row = $rs->fetch()) !== false) {
1023+
yield $row;
1024+
}
1025+
} finally {
1026+
$rs->closeCursor();
1027+
}
1028+
}
1029+
9901030
/**
9911031
* Returns all calendar objects with limited metadata for a calendar
9921032
*

apps/dav/lib/CalDAV/CalendarImpl.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
*/
99
namespace OCA\DAV\CalDAV;
1010

11+
use Generator;
1112
use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
1213
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
14+
use OCP\Calendar\CalendarExportOptions;
1315
use OCP\Calendar\Exceptions\CalendarException;
16+
use OCP\Calendar\ICalendarExport;
17+
use OCP\Calendar\ICalendarIsShared;
18+
use OCP\Calendar\ICalendarIsWritable;
1419
use OCP\Calendar\ICreateFromString;
1520
use OCP\Calendar\IHandleImipMessage;
1621
use OCP\Constants;
@@ -24,7 +29,7 @@
2429
use Sabre\VObject\Reader;
2530
use function Sabre\Uri\split as uriSplit;
2631

27-
class CalendarImpl implements ICreateFromString, IHandleImipMessage {
32+
class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport {
2833
public function __construct(
2934
private Calendar $calendar,
3035
/** @var array<string, mixed> */
@@ -257,4 +262,27 @@ public function handleIMipMessage(string $name, string $calendarData): void {
257262
public function getInvitationResponseServer(): InvitationResponseServer {
258263
return new InvitationResponseServer(false);
259264
}
265+
266+
/**
267+
* Export objects
268+
*
269+
* @since 32.0.0
270+
*
271+
* @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed>
272+
*/
273+
public function export(?CalendarExportOptions $options = null): Generator {
274+
foreach (
275+
$this->backend->exportCalendar(
276+
$this->calendarInfo['id'],
277+
$this->backend::CALENDAR_TYPE_CALENDAR,
278+
$options
279+
) as $event
280+
) {
281+
$vObject = Reader::read($event['calendardata']);
282+
if ($vObject instanceof VCalendar) {
283+
yield $vObject;
284+
}
285+
}
286+
}
287+
260288
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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\CalDAV\Export;
9+
10+
use Generator;
11+
use OCP\Calendar\CalendarExportOptions;
12+
use OCP\Calendar\ICalendarExport;
13+
use OCP\ServerVersion;
14+
use Sabre\VObject\Component;
15+
use Sabre\VObject\Writer;
16+
17+
/**
18+
* Calendar Export Service
19+
*/
20+
class ExportService {
21+
22+
public const FORMATS = ['ical', 'jcal', 'xcal'];
23+
private string $systemVersion;
24+
25+
public function __construct(ServerVersion $serverVersion) {
26+
$this->systemVersion = $serverVersion->getVersionString();
27+
}
28+
29+
/**
30+
* Generates serialized content stream for a calendar and objects based in selected format
31+
*
32+
* @return Generator<string>
33+
*/
34+
public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator {
35+
// output start of serialized content based on selected format
36+
yield $this->exportStart($options->getFormat());
37+
// iterate through each returned vCalendar entry
38+
// extract each component except timezones, convert to appropriate format and output
39+
// extract any timezones and save them but do not output
40+
$timezones = [];
41+
foreach ($calendar->export($options) as $entry) {
42+
$consecutive = false;
43+
foreach ($entry->getComponents() as $vComponent) {
44+
if ($vComponent->name === 'VTIMEZONE') {
45+
if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) {
46+
$timezones[$vComponent->TZID->getValue()] = clone $vComponent;
47+
}
48+
} else {
49+
yield $this->exportObject($vComponent, $options->getFormat(), $consecutive);
50+
$consecutive = true;
51+
}
52+
}
53+
}
54+
// iterate through each saved vTimezone entry, convert to appropriate format and output
55+
foreach ($timezones as $vComponent) {
56+
yield $this->exportObject($vComponent, $options->getFormat(), $consecutive);
57+
$consecutive = true;
58+
}
59+
// output end of serialized content based on selected format
60+
yield $this->exportFinish($options->getFormat());
61+
}
62+
63+
/**
64+
* Generates serialized content start based on selected format
65+
*/
66+
private function exportStart(string $format): string {
67+
return match ($format) {
68+
'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[',
69+
'xcal' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar Export v' . $this->systemVersion . '//EN</text></prodid></properties><components>',
70+
default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n"
71+
};
72+
}
73+
74+
/**
75+
* Generates serialized content end based on selected format
76+
*/
77+
private function exportFinish(string $format): string {
78+
return match ($format) {
79+
'jcal' => ']]',
80+
'xcal' => '</components></vcalendar></icalendar>',
81+
default => "END:VCALENDAR\n"
82+
};
83+
}
84+
85+
/**
86+
* Generates serialized content for a component based on selected format
87+
*/
88+
private function exportObject(Component $vobject, string $format, bool $consecutive): string {
89+
return match ($format) {
90+
'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject),
91+
'xcal' => $this->exportObjectXml($vobject),
92+
default => Writer::write($vobject)
93+
};
94+
}
95+
96+
/**
97+
* Generates serialized content for a component in xml format
98+
*/
99+
private function exportObjectXml(Component $vobject): string {
100+
$writer = new \Sabre\Xml\Writer();
101+
$writer->openMemory();
102+
$writer->setIndent(false);
103+
$vobject->xmlSerialize($writer);
104+
return $writer->outputMemory();
105+
}
106+
107+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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\Command;
9+
10+
use InvalidArgumentException;
11+
use OCA\DAV\CalDAV\Export\ExportService;
12+
use OCP\Calendar\CalendarExportOptions;
13+
use OCP\Calendar\ICalendarExport;
14+
use OCP\Calendar\IManager;
15+
use OCP\IUserManager;
16+
use Symfony\Component\Console\Attribute\AsCommand;
17+
use Symfony\Component\Console\Command\Command;
18+
use Symfony\Component\Console\Input\InputArgument;
19+
use Symfony\Component\Console\Input\InputInterface;
20+
use Symfony\Component\Console\Input\InputOption;
21+
use Symfony\Component\Console\Output\OutputInterface;
22+
23+
/**
24+
* Calendar Export Command
25+
*
26+
* Used to export data from supported calendars to disk or stdout
27+
*/
28+
#[AsCommand(
29+
name: 'calendar:export',
30+
description: 'Export calendar data from supported calendars to disk or stdout',
31+
hidden: false
32+
)]
33+
class ExportCalendar extends Command {
34+
public function __construct(
35+
private IUserManager $userManager,
36+
private IManager $calendarManager,
37+
private ExportService $exportService,
38+
) {
39+
parent::__construct();
40+
}
41+
42+
protected function configure(): void {
43+
$this->setName('calendar:export')
44+
->setDescription('Export calendar data from supported calendars to disk or stdout')
45+
->addArgument('uid', InputArgument::REQUIRED, 'Id of system user')
46+
->addArgument('uri', InputArgument::REQUIRED, 'Uri of calendar')
47+
->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of output (ical, jcal, xcal) defaults to ical', 'ical')
48+
->addOption('location', null, InputOption::VALUE_REQUIRED, 'Location of where to write the output. defaults to stdout');
49+
}
50+
51+
protected function execute(InputInterface $input, OutputInterface $output): int {
52+
$userId = $input->getArgument('uid');
53+
$calendarId = $input->getArgument('uri');
54+
$format = $input->getOption('format');
55+
$location = $input->getOption('location');
56+
57+
if (!$this->userManager->userExists($userId)) {
58+
throw new InvalidArgumentException("User <$userId> not found.");
59+
}
60+
// retrieve calendar and evaluate if export is supported
61+
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]);
62+
if ($calendars === []) {
63+
throw new InvalidArgumentException("Calendar <$calendarId> not found.");
64+
}
65+
$calendar = $calendars[0];
66+
if (!$calendar instanceof ICalendarExport) {
67+
throw new InvalidArgumentException("Calendar <$calendarId> does not support exporting");
68+
}
69+
// construct options object
70+
$options = new CalendarExportOptions();
71+
// evaluate if provided format is supported
72+
if (!in_array($format, ExportService::FORMATS, true)) {
73+
throw new InvalidArgumentException("Format <$format> is not valid.");
74+
}
75+
$options->setFormat($format);
76+
// evaluate is a valid location was given and is usable otherwise output to stdout
77+
if ($location !== null) {
78+
$handle = fopen($location, 'wb');
79+
if ($handle === false) {
80+
throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation.");
81+
}
82+
83+
foreach ($this->exportService->export($calendar, $options) as $chunk) {
84+
fwrite($handle, $chunk);
85+
}
86+
fclose($handle);
87+
} else {
88+
foreach ($this->exportService->export($calendar, $options) as $chunk) {
89+
$output->writeln($chunk);
90+
}
91+
}
92+
93+
return self::SUCCESS;
94+
}
95+
}

apps/dav/lib/Listener/AddMissingIndicesListener.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ public function handle(Event $event): void {
3030
'dav_shares_resourceid_access',
3131
['resourceid', 'access']
3232
);
33+
$event->addMissingIndex(
34+
'calendarobjects',
35+
'calobjects_by_uid_index',
36+
['calendarid', 'calendartype', 'uid']
37+
);
3338
}
3439

3540
}

apps/dav/lib/Migration/Version1006Date20180628111625.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $op
4949
$calendarObjectsTable->dropIndex('calobjects_index');
5050
}
5151
$calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uri'], 'calobjects_index');
52+
$calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uid'], 'calobjects_by_uid_index');
5253
}
5354

5455
if ($schema->hasTable('calendarobjects_props')) {

0 commit comments

Comments
 (0)