aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSebastianKrupinski <krupinskis05@gmail.com>2025-04-03 19:54:08 -0400
committerDaniel Kesselberg <mail@danielkesselberg.de>2025-05-06 11:09:33 +0200
commita2d4f8d3f13a1f94db24d05c477815d2fe0b16f6 (patch)
tree77a75f74d3736ddd2a72caf84b037c1e43b21133
parentcd9f0350b0c8201b528f5f86883e68928018e512 (diff)
downloadnextcloud-server-a2d4f8d3f13a1f94db24d05c477815d2fe0b16f6.tar.gz
nextcloud-server-a2d4f8d3f13a1f94db24d05c477815d2fe0b16f6.zip
feat: Calendar Exportfeat/issue-563-calendar-export
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
-rw-r--r--apps/dav/appinfo/info.xml1
-rw-r--r--apps/dav/composer/composer/autoload_classmap.php2
-rw-r--r--apps/dav/composer/composer/autoload_static.php2
-rw-r--r--apps/dav/lib/CalDAV/CalDavBackend.php40
-rw-r--r--apps/dav/lib/CalDAV/CalendarImpl.php30
-rw-r--r--apps/dav/lib/CalDAV/Export/ExportService.php107
-rw-r--r--apps/dav/lib/Command/ExportCalendar.php95
-rw-r--r--apps/dav/lib/Listener/AddMissingIndicesListener.php5
-rw-r--r--apps/dav/lib/Migration/Version1006Date20180628111625.php1
-rw-r--r--apps/dav/tests/unit/CalDAV/CalendarImplTest.php71
-rw-r--r--apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php80
-rw-r--r--lib/composer/composer/autoload_classmap.php2
-rw-r--r--lib/composer/composer/autoload_static.php2
-rw-r--r--lib/public/Calendar/CalendarExportOptions.php68
-rw-r--r--lib/public/Calendar/ICalendarExport.php31
15 files changed, 522 insertions, 15 deletions
diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml
index a99bea224b6..ac8886555f6 100644
--- a/apps/dav/appinfo/info.xml
+++ b/apps/dav/appinfo/info.xml
@@ -60,6 +60,7 @@
<command>OCA\DAV\Command\CreateSubscription</command>
<command>OCA\DAV\Command\DeleteCalendar</command>
<command>OCA\DAV\Command\DeleteSubscription</command>
+ <command>OCA\DAV\Command\ExportCalendar</command>
<command>OCA\DAV\Command\FixCalendarSyncCommand</command>
<command>OCA\DAV\Command\ListAddressbooks</command>
<command>OCA\DAV\Command\ListCalendars</command>
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index 0e0ae51bb7a..94626cce752 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -64,6 +64,7 @@ return array(
'OCA\\DAV\\CalDAV\\EventReader' => $baseDir . '/../lib/CalDAV/EventReader.php',
'OCA\\DAV\\CalDAV\\EventReaderRDate' => $baseDir . '/../lib/CalDAV/EventReaderRDate.php',
'OCA\\DAV\\CalDAV\\EventReaderRRule' => $baseDir . '/../lib/CalDAV/EventReaderRRule.php',
+ 'OCA\\DAV\\CalDAV\\Export\\ExportService' => $baseDir . '/../lib/CalDAV/Export/ExportService.php',
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
@@ -159,6 +160,7 @@ return array(
'OCA\\DAV\\Command\\CreateSubscription' => $baseDir . '/../lib/Command/CreateSubscription.php',
'OCA\\DAV\\Command\\DeleteCalendar' => $baseDir . '/../lib/Command/DeleteCalendar.php',
'OCA\\DAV\\Command\\DeleteSubscription' => $baseDir . '/../lib/Command/DeleteSubscription.php',
+ 'OCA\\DAV\\Command\\ExportCalendar' => $baseDir . '/../lib/Command/ExportCalendar.php',
'OCA\\DAV\\Command\\FixCalendarSyncCommand' => $baseDir . '/../lib/Command/FixCalendarSyncCommand.php',
'OCA\\DAV\\Command\\ListAddressbooks' => $baseDir . '/../lib/Command/ListAddressbooks.php',
'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index c6ac9757c5b..b30da1d48c9 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -79,6 +79,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\EventReader' => __DIR__ . '/..' . '/../lib/CalDAV/EventReader.php',
'OCA\\DAV\\CalDAV\\EventReaderRDate' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRDate.php',
'OCA\\DAV\\CalDAV\\EventReaderRRule' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRRule.php',
+ 'OCA\\DAV\\CalDAV\\Export\\ExportService' => __DIR__ . '/..' . '/../lib/CalDAV/Export/ExportService.php',
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
@@ -174,6 +175,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Command\\CreateSubscription' => __DIR__ . '/..' . '/../lib/Command/CreateSubscription.php',
'OCA\\DAV\\Command\\DeleteCalendar' => __DIR__ . '/..' . '/../lib/Command/DeleteCalendar.php',
'OCA\\DAV\\Command\\DeleteSubscription' => __DIR__ . '/..' . '/../lib/Command/DeleteSubscription.php',
+ 'OCA\\DAV\\Command\\ExportCalendar' => __DIR__ . '/..' . '/../lib/Command/ExportCalendar.php',
'OCA\\DAV\\Command\\FixCalendarSyncCommand' => __DIR__ . '/..' . '/../lib/Command/FixCalendarSyncCommand.php',
'OCA\\DAV\\Command\\ListAddressbooks' => __DIR__ . '/..' . '/../lib/Command/ListAddressbooks.php',
'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php',
diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php
index 2ef57ca77bb..e69fe9ed3f0 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -9,6 +9,7 @@ namespace OCA\DAV\CalDAV;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
+use Generator;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\Sharing\Backend;
use OCA\DAV\Connector\Sabre\Principal;
@@ -28,6 +29,7 @@ use OCA\DAV\Events\SubscriptionCreatedEvent;
use OCA\DAV\Events\SubscriptionDeletedEvent;
use OCA\DAV\Events\SubscriptionUpdatedEvent;
use OCP\AppFramework\Db\TTransactional;
+use OCP\Calendar\CalendarExportOptions;
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
use OCP\Calendar\Events\CalendarObjectDeletedEvent;
use OCP\Calendar\Events\CalendarObjectMovedEvent;
@@ -988,6 +990,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
/**
+ * Returns all calendar entries as a stream of data
+ *
+ * @since 32.0.0
+ *
+ * @return Generator<array>
+ */
+ public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator {
+ // extract options
+ $rangeStart = $options?->getRangeStart();
+ $rangeCount = $options?->getRangeCount();
+ // construct query
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from('calendarobjects')
+ ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
+ ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
+ ->andWhere($qb->expr()->isNull('deleted_at'));
+ if ($rangeStart !== null) {
+ $qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart)));
+ }
+ if ($rangeCount !== null) {
+ $qb->setMaxResults($rangeCount);
+ }
+ if ($rangeStart !== null || $rangeCount !== null) {
+ $qb->orderBy('uid', 'ASC');
+ }
+ $rs = $qb->executeQuery();
+ // iterate through results
+ try {
+ while (($row = $rs->fetch()) !== false) {
+ yield $row;
+ }
+ } finally {
+ $rs->closeCursor();
+ }
+ }
+
+ /**
* Returns all calendar objects with limited metadata for a calendar
*
* Every item contains an array with the following keys:
diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php
index b3062f005ee..46f1b9aef9d 100644
--- a/apps/dav/lib/CalDAV/CalendarImpl.php
+++ b/apps/dav/lib/CalDAV/CalendarImpl.php
@@ -8,9 +8,14 @@ declare(strict_types=1);
*/
namespace OCA\DAV\CalDAV;
+use Generator;
use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
+use OCP\Calendar\CalendarExportOptions;
use OCP\Calendar\Exceptions\CalendarException;
+use OCP\Calendar\ICalendarExport;
+use OCP\Calendar\ICalendarIsShared;
+use OCP\Calendar\ICalendarIsWritable;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IHandleImipMessage;
use OCP\Constants;
@@ -24,7 +29,7 @@ use Sabre\VObject\Property;
use Sabre\VObject\Reader;
use function Sabre\Uri\split as uriSplit;
-class CalendarImpl implements ICreateFromString, IHandleImipMessage {
+class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport {
public function __construct(
private Calendar $calendar,
/** @var array<string, mixed> */
@@ -257,4 +262,27 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage {
public function getInvitationResponseServer(): InvitationResponseServer {
return new InvitationResponseServer(false);
}
+
+ /**
+ * Export objects
+ *
+ * @since 32.0.0
+ *
+ * @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed>
+ */
+ public function export(?CalendarExportOptions $options = null): Generator {
+ foreach (
+ $this->backend->exportCalendar(
+ $this->calendarInfo['id'],
+ $this->backend::CALENDAR_TYPE_CALENDAR,
+ $options
+ ) as $event
+ ) {
+ $vObject = Reader::read($event['calendardata']);
+ if ($vObject instanceof VCalendar) {
+ yield $vObject;
+ }
+ }
+ }
+
}
diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php
new file mode 100644
index 00000000000..393c53b92e4
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Export/ExportService.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Export;
+
+use Generator;
+use OCP\Calendar\CalendarExportOptions;
+use OCP\Calendar\ICalendarExport;
+use OCP\ServerVersion;
+use Sabre\VObject\Component;
+use Sabre\VObject\Writer;
+
+/**
+ * Calendar Export Service
+ */
+class ExportService {
+
+ public const FORMATS = ['ical', 'jcal', 'xcal'];
+ private string $systemVersion;
+
+ public function __construct(ServerVersion $serverVersion) {
+ $this->systemVersion = $serverVersion->getVersionString();
+ }
+
+ /**
+ * Generates serialized content stream for a calendar and objects based in selected format
+ *
+ * @return Generator<string>
+ */
+ public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator {
+ // output start of serialized content based on selected format
+ yield $this->exportStart($options->getFormat());
+ // iterate through each returned vCalendar entry
+ // extract each component except timezones, convert to appropriate format and output
+ // extract any timezones and save them but do not output
+ $timezones = [];
+ foreach ($calendar->export($options) as $entry) {
+ $consecutive = false;
+ foreach ($entry->getComponents() as $vComponent) {
+ if ($vComponent->name === 'VTIMEZONE') {
+ if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) {
+ $timezones[$vComponent->TZID->getValue()] = clone $vComponent;
+ }
+ } else {
+ yield $this->exportObject($vComponent, $options->getFormat(), $consecutive);
+ $consecutive = true;
+ }
+ }
+ }
+ // iterate through each saved vTimezone entry, convert to appropriate format and output
+ foreach ($timezones as $vComponent) {
+ yield $this->exportObject($vComponent, $options->getFormat(), $consecutive);
+ $consecutive = true;
+ }
+ // output end of serialized content based on selected format
+ yield $this->exportFinish($options->getFormat());
+ }
+
+ /**
+ * Generates serialized content start based on selected format
+ */
+ private function exportStart(string $format): string {
+ return match ($format) {
+ 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[',
+ '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>',
+ default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n"
+ };
+ }
+
+ /**
+ * Generates serialized content end based on selected format
+ */
+ private function exportFinish(string $format): string {
+ return match ($format) {
+ 'jcal' => ']]',
+ 'xcal' => '</components></vcalendar></icalendar>',
+ default => "END:VCALENDAR\n"
+ };
+ }
+
+ /**
+ * Generates serialized content for a component based on selected format
+ */
+ private function exportObject(Component $vobject, string $format, bool $consecutive): string {
+ return match ($format) {
+ 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject),
+ 'xcal' => $this->exportObjectXml($vobject),
+ default => Writer::write($vobject)
+ };
+ }
+
+ /**
+ * Generates serialized content for a component in xml format
+ */
+ private function exportObjectXml(Component $vobject): string {
+ $writer = new \Sabre\Xml\Writer();
+ $writer->openMemory();
+ $writer->setIndent(false);
+ $vobject->xmlSerialize($writer);
+ return $writer->outputMemory();
+ }
+
+}
diff --git a/apps/dav/lib/Command/ExportCalendar.php b/apps/dav/lib/Command/ExportCalendar.php
new file mode 100644
index 00000000000..5758cd4fa87
--- /dev/null
+++ b/apps/dav/lib/Command/ExportCalendar.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Command;
+
+use InvalidArgumentException;
+use OCA\DAV\CalDAV\Export\ExportService;
+use OCP\Calendar\CalendarExportOptions;
+use OCP\Calendar\ICalendarExport;
+use OCP\Calendar\IManager;
+use OCP\IUserManager;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Calendar Export Command
+ *
+ * Used to export data from supported calendars to disk or stdout
+ */
+#[AsCommand(
+ name: 'calendar:export',
+ description: 'Export calendar data from supported calendars to disk or stdout',
+ hidden: false
+)]
+class ExportCalendar extends Command {
+ public function __construct(
+ private IUserManager $userManager,
+ private IManager $calendarManager,
+ private ExportService $exportService,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this->setName('calendar:export')
+ ->setDescription('Export calendar data from supported calendars to disk or stdout')
+ ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user')
+ ->addArgument('uri', InputArgument::REQUIRED, 'Uri of calendar')
+ ->addOption('format', null, InputOption::VALUE_REQUIRED, 'Format of output (ical, jcal, xcal) defaults to ical', 'ical')
+ ->addOption('location', null, InputOption::VALUE_REQUIRED, 'Location of where to write the output. defaults to stdout');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $userId = $input->getArgument('uid');
+ $calendarId = $input->getArgument('uri');
+ $format = $input->getOption('format');
+ $location = $input->getOption('location');
+
+ if (!$this->userManager->userExists($userId)) {
+ throw new InvalidArgumentException("User <$userId> not found.");
+ }
+ // retrieve calendar and evaluate if export is supported
+ $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]);
+ if ($calendars === []) {
+ throw new InvalidArgumentException("Calendar <$calendarId> not found.");
+ }
+ $calendar = $calendars[0];
+ if (!$calendar instanceof ICalendarExport) {
+ throw new InvalidArgumentException("Calendar <$calendarId> does not support exporting");
+ }
+ // construct options object
+ $options = new CalendarExportOptions();
+ // evaluate if provided format is supported
+ if (!in_array($format, ExportService::FORMATS, true)) {
+ throw new InvalidArgumentException("Format <$format> is not valid.");
+ }
+ $options->setFormat($format);
+ // evaluate is a valid location was given and is usable otherwise output to stdout
+ if ($location !== null) {
+ $handle = fopen($location, 'wb');
+ if ($handle === false) {
+ throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation.");
+ }
+
+ foreach ($this->exportService->export($calendar, $options) as $chunk) {
+ fwrite($handle, $chunk);
+ }
+ fclose($handle);
+ } else {
+ foreach ($this->exportService->export($calendar, $options) as $chunk) {
+ $output->writeln($chunk);
+ }
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/apps/dav/lib/Listener/AddMissingIndicesListener.php b/apps/dav/lib/Listener/AddMissingIndicesListener.php
index 035c6c9582e..d3a1cf4b224 100644
--- a/apps/dav/lib/Listener/AddMissingIndicesListener.php
+++ b/apps/dav/lib/Listener/AddMissingIndicesListener.php
@@ -30,6 +30,11 @@ class AddMissingIndicesListener implements IEventListener {
'dav_shares_resourceid_access',
['resourceid', 'access']
);
+ $event->addMissingIndex(
+ 'calendarobjects',
+ 'calobjects_by_uid_index',
+ ['calendarid', 'calendartype', 'uid']
+ );
}
}
diff --git a/apps/dav/lib/Migration/Version1006Date20180628111625.php b/apps/dav/lib/Migration/Version1006Date20180628111625.php
index 5f3aa4b6fe2..f4be26e6ad0 100644
--- a/apps/dav/lib/Migration/Version1006Date20180628111625.php
+++ b/apps/dav/lib/Migration/Version1006Date20180628111625.php
@@ -49,6 +49,7 @@ class Version1006Date20180628111625 extends SimpleMigrationStep {
$calendarObjectsTable->dropIndex('calobjects_index');
}
$calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uri'], 'calobjects_index');
+ $calendarObjectsTable->addUniqueIndex(['calendarid', 'calendartype', 'uid'], 'calobjects_by_uid_index');
}
if ($schema->hasTable('calendarobjects_props')) {
diff --git a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php
index ee9b85fafe8..0d5223739f3 100644
--- a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php
+++ b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php
@@ -5,6 +5,7 @@
*/
namespace OCA\DAV\Tests\unit\CalDAV;
+use Generator;
use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
@@ -20,24 +21,19 @@ use Sabre\VObject\ITip\Message;
use Sabre\VObject\Reader;
class CalendarImplTest extends \Test\TestCase {
- /** @var CalendarImpl */
- private $calendarImpl;
- /** @var Calendar | \PHPUnit\Framework\MockObject\MockObject */
- private $calendar;
-
- /** @var array */
- private $calendarInfo;
-
- /** @var CalDavBackend | \PHPUnit\Framework\MockObject\MockObject */
- private $backend;
+ private Calendar|MockObject $calendar;
+ private array $calendarInfo;
+ private CalDavBackend|MockObject $backend;
+ private CalendarImpl|MockObject $calendarImpl;
+ private array $mockExportCollection;
protected function setUp(): void {
parent::setUp();
$this->calendar = $this->createMock(Calendar::class);
$this->calendarInfo = [
- 'id' => 'fancy_id_123',
+ 'id' => 1,
'{DAV:}displayname' => 'user readable name 123',
'{http://apple.com/ns/ical/}calendar-color' => '#AABBCC',
'uri' => '/this/is/a/uri',
@@ -45,13 +41,16 @@ class CalendarImplTest extends \Test\TestCase {
];
$this->backend = $this->createMock(CalDavBackend::class);
- $this->calendarImpl = new CalendarImpl($this->calendar,
- $this->calendarInfo, $this->backend);
+ $this->calendarImpl = new CalendarImpl(
+ $this->calendar,
+ $this->calendarInfo,
+ $this->backend
+ );
}
public function testGetKey(): void {
- $this->assertEquals($this->calendarImpl->getKey(), 'fancy_id_123');
+ $this->assertEquals($this->calendarImpl->getKey(), 1);
}
public function testGetDisplayname(): void {
@@ -261,4 +260,48 @@ EOF;
$iTipMessage->message = $vObject;
return $iTipMessage;
}
+
+ protected function mockExportGenerator(): Generator {
+ foreach ($this->mockExportCollection as $entry) {
+ yield $entry;
+ }
+ }
+
+ public function testExport(): void {
+ // Arrange
+ // construct calendar with a 1 hour event and same start/end time zones
+ $vCalendar = new VCalendar();
+ /** @var VEvent $vEvent */
+ $vEvent = $vCalendar->add('VEVENT', []);
+ $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
+ $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
+ $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']);
+ $vEvent->add('SUMMARY', 'Test Recurrence Event');
+ $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']);
+ $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [
+ 'CN' => 'Attendee One',
+ 'CUTYPE' => 'INDIVIDUAL',
+ 'PARTSTAT' => 'NEEDS-ACTION',
+ 'ROLE' => 'REQ-PARTICIPANT',
+ 'RSVP' => 'TRUE'
+ ]);
+ // construct data store return
+ $this->mockExportCollection[] = [
+ 'id' => 1,
+ 'calendardata' => $vCalendar->serialize()
+ ];
+ $this->backend->expects($this->once())
+ ->method('exportCalendar')
+ ->with(1, $this->backend::CALENDAR_TYPE_CALENDAR, null)
+ ->willReturn($this->mockExportGenerator());
+
+ // Act
+ foreach ($this->calendarImpl->export(null) as $entry) {
+ $exported[] = $entry;
+ }
+
+ // Assert
+ $this->assertCount(1, $exported, 'Invalid exported items count');
+ }
+
}
diff --git a/apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php b/apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php
new file mode 100644
index 00000000000..f1e049c4a80
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Export/ExportServiceTest.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\Tests\unit\CalDAV\Export;
+
+use Generator;
+use OCA\DAV\CalDAV\Export\ExportService;
+use OCP\Calendar\CalendarExportOptions;
+use OCP\Calendar\ICalendarExport;
+use OCP\ServerVersion;
+use PHPUnit\Framework\MockObject\MockObject;
+use Sabre\VObject\Component\VCalendar;
+
+class ExportServiceTest extends \Test\TestCase {
+
+ private ServerVersion|MockObject $serverVersion;
+ private ExportService $service;
+ private ICalendarExport|MockObject $calendar;
+ private array $mockExportCollection;
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->serverVersion = $this->createMock(ServerVersion::class);
+ $this->serverVersion->method('getVersionString')
+ ->willReturn('32.0.0.0');
+ $this->service = new ExportService($this->serverVersion);
+ $this->calendar = $this->createMock(ICalendarExport::class);
+
+ }
+
+ protected function mockGenerator(): Generator {
+ foreach ($this->mockExportCollection as $entry) {
+ yield $entry;
+ }
+ }
+
+ public function testExport(): void {
+ // Arrange
+ // construct calendar with a 1 hour event and same start/end time zones
+ $vCalendar = new VCalendar();
+ /** @var \Sabre\VObject\Component\VEvent $vEvent */
+ $vEvent = $vCalendar->add('VEVENT', []);
+ $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc');
+ $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']);
+ $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']);
+ $vEvent->add('SUMMARY', 'Test Recurrence Event');
+ $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']);
+ $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [
+ 'CN' => 'Attendee One',
+ 'CUTYPE' => 'INDIVIDUAL',
+ 'PARTSTAT' => 'NEEDS-ACTION',
+ 'ROLE' => 'REQ-PARTICIPANT',
+ 'RSVP' => 'TRUE'
+ ]);
+ // construct calendar return
+ $options = new CalendarExportOptions();
+ $this->mockExportCollection[] = $vCalendar;
+ $this->calendar->expects($this->once())
+ ->method('export')
+ ->with($options)
+ ->willReturn($this->mockGenerator());
+
+ // Act
+ $document = '';
+ foreach ($this->service->export($this->calendar, $options) as $chunk) {
+ $document .= $chunk;
+ }
+
+ // Assert
+ $this->assertStringContainsString('BEGIN:VCALENDAR', $document, 'Exported document calendar start missing');
+ $this->assertStringContainsString('BEGIN:VEVENT', $document, 'Exported document event start missing');
+ $this->assertStringContainsString('END:VEVENT', $document, 'Exported document event end missing');
+ $this->assertStringContainsString('END:VCALENDAR', $document, 'Exported document calendar end missing');
+
+ }
+
+}
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 6264126b028..177abfefe8e 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -191,6 +191,7 @@ return array(
'OCP\\Cache\\CappedMemoryCache' => $baseDir . '/lib/public/Cache/CappedMemoryCache.php',
'OCP\\Calendar\\BackendTemporarilyUnavailableException' => $baseDir . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php',
'OCP\\Calendar\\CalendarEventStatus' => $baseDir . '/lib/public/Calendar/CalendarEventStatus.php',
+ 'OCP\\Calendar\\CalendarExportOptions' => $baseDir . '/lib/public/Calendar/CalendarExportOptions.php',
'OCP\\Calendar\\Events\\AbstractCalendarObjectEvent' => $baseDir . '/lib/public/Calendar/Events/AbstractCalendarObjectEvent.php',
'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent' => $baseDir . '/lib/public/Calendar/Events/CalendarObjectCreatedEvent.php',
'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent' => $baseDir . '/lib/public/Calendar/Events/CalendarObjectDeletedEvent.php',
@@ -202,6 +203,7 @@ return array(
'OCP\\Calendar\\IAvailabilityResult' => $baseDir . '/lib/public/Calendar/IAvailabilityResult.php',
'OCP\\Calendar\\ICalendar' => $baseDir . '/lib/public/Calendar/ICalendar.php',
'OCP\\Calendar\\ICalendarEventBuilder' => $baseDir . '/lib/public/Calendar/ICalendarEventBuilder.php',
+ 'OCP\\Calendar\\ICalendarExport' => $baseDir . '/lib/public/Calendar/ICalendarExport.php',
'OCP\\Calendar\\ICalendarIsShared' => $baseDir . '/lib/public/Calendar/ICalendarIsShared.php',
'OCP\\Calendar\\ICalendarIsWritable' => $baseDir . '/lib/public/Calendar/ICalendarIsWritable.php',
'OCP\\Calendar\\ICalendarProvider' => $baseDir . '/lib/public/Calendar/ICalendarProvider.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 5771a621afe..ddccd0692ca 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -232,6 +232,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Cache\\CappedMemoryCache' => __DIR__ . '/../../..' . '/lib/public/Cache/CappedMemoryCache.php',
'OCP\\Calendar\\BackendTemporarilyUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php',
'OCP\\Calendar\\CalendarEventStatus' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarEventStatus.php',
+ 'OCP\\Calendar\\CalendarExportOptions' => __DIR__ . '/../../..' . '/lib/public/Calendar/CalendarExportOptions.php',
'OCP\\Calendar\\Events\\AbstractCalendarObjectEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/AbstractCalendarObjectEvent.php',
'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/CalendarObjectCreatedEvent.php',
'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent' => __DIR__ . '/../../..' . '/lib/public/Calendar/Events/CalendarObjectDeletedEvent.php',
@@ -243,6 +244,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Calendar\\IAvailabilityResult' => __DIR__ . '/../../..' . '/lib/public/Calendar/IAvailabilityResult.php',
'OCP\\Calendar\\ICalendar' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendar.php',
'OCP\\Calendar\\ICalendarEventBuilder' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarEventBuilder.php',
+ 'OCP\\Calendar\\ICalendarExport' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarExport.php',
'OCP\\Calendar\\ICalendarIsShared' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsShared.php',
'OCP\\Calendar\\ICalendarIsWritable' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsWritable.php',
'OCP\\Calendar\\ICalendarProvider' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarProvider.php',
diff --git a/lib/public/Calendar/CalendarExportOptions.php b/lib/public/Calendar/CalendarExportOptions.php
new file mode 100644
index 00000000000..bf21dd85ae4
--- /dev/null
+++ b/lib/public/Calendar/CalendarExportOptions.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCP\Calendar;
+
+/**
+ * Calendar Export Options
+ *
+ * @since 32.0.0
+ */
+final class CalendarExportOptions {
+
+ /** @var 'ical'|'jcal'|'xcal' */
+ private string $format = 'ical';
+ private ?string $rangeStart = null;
+ private ?int $rangeCount = null;
+
+ /**
+ * Gets the export format
+ *
+ * @return 'ical'|'jcal'|'xcal' (defaults to ical)
+ */
+ public function getFormat(): string {
+ return $this->format;
+ }
+
+ /**
+ * Sets the export format
+ *
+ * @param 'ical'|'jcal'|'xcal' $format
+ */
+ public function setFormat(string $format): void {
+ $this->format = $format;
+ }
+
+ /**
+ * Gets the start of the range to export
+ */
+ public function getRangeStart(): ?string {
+ return $this->rangeStart;
+ }
+
+ /**
+ * Sets the start of the range to export
+ */
+ public function setRangeStart(?string $rangeStart): void {
+ $this->rangeStart = $rangeStart;
+ }
+
+ /**
+ * Gets the number of objects to export
+ */
+ public function getRangeCount(): ?int {
+ return $this->rangeCount;
+ }
+
+ /**
+ * Sets the number of objects to export
+ */
+ public function setRangeCount(?int $rangeCount): void {
+ $this->rangeCount = $rangeCount;
+ }
+}
diff --git a/lib/public/Calendar/ICalendarExport.php b/lib/public/Calendar/ICalendarExport.php
new file mode 100644
index 00000000000..61b286e1668
--- /dev/null
+++ b/lib/public/Calendar/ICalendarExport.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCP\Calendar;
+
+use Generator;
+
+/**
+ * ICalendar Interface Extension to export data
+ *
+ * @since 32.0.0
+ */
+interface ICalendarExport {
+
+ /**
+ * Export objects
+ *
+ * @since 32.0.0
+ *
+ * @param CalendarExportOptions|null $options
+ *
+ * @return Generator<\Sabre\VObject\Component\VCalendar>
+ */
+ public function export(?CalendarExportOptions $options): Generator;
+
+}