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