Merge pull request #51924 from nextcloud/feat/issue-563-calendar-export
feat: Calendar Exportpull/52253/head
commit
31899d95b9
@ -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(); |
||||
} |
||||
|
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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'); |
||||
|
||||
} |
||||
|
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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; |
||||
|
||||
} |
Loading…
Reference in new issue