Merge pull request #30963 from nextcloud/feat/calendar-migration

pull/31526/head
Pytal 2022-03-10 11:34:11 +07:00 committed by GitHub
commit eeec6142ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1288 additions and 0 deletions

@ -299,4 +299,7 @@ return array(
'OCA\\DAV\\Upload\\UploadFile' => $baseDir . '/../lib/Upload/UploadFile.php',
'OCA\\DAV\\Upload\\UploadFolder' => $baseDir . '/../lib/Upload/UploadFolder.php',
'OCA\\DAV\\Upload\\UploadHome' => $baseDir . '/../lib/Upload/UploadHome.php',
'OCA\\DAV\\UserMigration\\CalendarMigrator' => $baseDir . '/../lib/UserMigration/CalendarMigrator.php',
'OCA\\DAV\\UserMigration\\CalendarMigratorException' => $baseDir . '/../lib/UserMigration/CalendarMigratorException.php',
'OCA\\DAV\\UserMigration\\InvalidCalendarException' => $baseDir . '/../lib/UserMigration/InvalidCalendarException.php',
);

@ -314,6 +314,9 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Upload\\UploadFile' => __DIR__ . '/..' . '/../lib/Upload/UploadFile.php',
'OCA\\DAV\\Upload\\UploadFolder' => __DIR__ . '/..' . '/../lib/Upload/UploadFolder.php',
'OCA\\DAV\\Upload\\UploadHome' => __DIR__ . '/..' . '/../lib/Upload/UploadHome.php',
'OCA\\DAV\\UserMigration\\CalendarMigrator' => __DIR__ . '/..' . '/../lib/UserMigration/CalendarMigrator.php',
'OCA\\DAV\\UserMigration\\CalendarMigratorException' => __DIR__ . '/..' . '/../lib/UserMigration/CalendarMigratorException.php',
'OCA\\DAV\\UserMigration\\InvalidCalendarException' => __DIR__ . '/..' . '/../lib/UserMigration/InvalidCalendarException.php',
);
public static function getInitializer(ClassLoader $loader)

@ -80,6 +80,7 @@ use OCA\DAV\Listener\CardListener;
use OCA\DAV\Search\ContactsSearchProvider;
use OCA\DAV\Search\EventsSearchProvider;
use OCA\DAV\Search\TasksSearchProvider;
use OCA\DAV\UserMigration\CalendarMigrator;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@ -165,6 +166,8 @@ class Application extends App implements IBootstrap {
$context->registerNotifierService(Notifier::class);
$context->registerCalendarProvider(CalendarProvider::class);
$context->registerUserMigrator(CalendarMigrator::class);
}
public function boot(IBootContext $context): void {

@ -69,6 +69,13 @@ class CalendarImpl implements ICreateFromString {
return $this->calendarInfo['id'];
}
/**
* {@inheritDoc}
*/
public function getUri(): string {
return $this->calendarInfo['uri'];
}
/**
* In comparison to getKey() this function returns a human readable (maybe translated) name
* @return null|string

@ -0,0 +1,460 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\DAV\UserMigration;
use function Safe\substr;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin;
use OCA\DAV\CalDAV\Plugin as CalDAVPlugin;
use OCA\DAV\Connector\Sabre\CachingTree;
use OCA\DAV\Connector\Sabre\Server as SabreDavServer;
use OCA\DAV\RootCollection;
use OCP\Calendar\ICalendar;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\Defaults;
use OCP\IL10N;
use OCP\IUser;
use OCP\UserMigration\IExportDestination;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\IMigrator;
use OCP\UserMigration\TMigratorBasicVersionHandling;
use Sabre\VObject\Component as VObjectComponent;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VTimeZone;
use Sabre\VObject\Property\ICalendar\DateTime;
use Sabre\VObject\Reader as VObjectReader;
use Sabre\VObject\UUIDUtil;
use Safe\Exceptions\StringsException;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
class CalendarMigrator implements IMigrator {
use TMigratorBasicVersionHandling;
private CalDavBackend $calDavBackend;
private ICalendarManager $calendarManager;
// ICSExportPlugin is injected as the mergeObjects() method is required and is not to be used as a SabreDAV server plugin
private ICSExportPlugin $icsExportPlugin;
private Defaults $defaults;
private IL10N $l10n;
private SabreDavServer $sabreDavServer;
private const USERS_URI_ROOT = 'principals/users/';
private const FILENAME_EXT = '.ics';
private const MIGRATED_URI_PREFIX = 'migrated-';
private const EXPORT_ROOT = Application::APP_ID . '/calendars/';
public function __construct(
CalDavBackend $calDavBackend,
ICalendarManager $calendarManager,
ICSExportPlugin $icsExportPlugin,
Defaults $defaults,
IL10N $l10n
) {
$this->calDavBackend = $calDavBackend;
$this->calendarManager = $calendarManager;
$this->icsExportPlugin = $icsExportPlugin;
$this->defaults = $defaults;
$this->l10n = $l10n;
$root = new RootCollection();
$this->sabreDavServer = new SabreDavServer(new CachingTree($root));
$this->sabreDavServer->addPlugin(new CalDAVPlugin());
}
private function getPrincipalUri(IUser $user): string {
return CalendarMigrator::USERS_URI_ROOT . $user->getUID();
}
/**
* @return array{name: string, vCalendar: VCalendar}
*
* @throws CalendarMigratorException
* @throws InvalidCalendarException
*/
private function getCalendarExportData(IUser $user, ICalendar $calendar, OutputInterface $output): array {
$userId = $user->getUID();
$calendarId = $calendar->getKey();
$calendarInfo = $this->calDavBackend->getCalendarById($calendarId);
if (empty($calendarInfo)) {
throw new CalendarMigratorException("Invalid info for calendar ID $calendarId");
}
$uri = $calendarInfo['uri'];
$path = CalDAVPlugin::CALENDAR_ROOT . "/$userId/$uri";
/**
* @see \Sabre\CalDAV\ICSExportPlugin::httpGet() implementation reference
*/
$properties = $this->sabreDavServer->getProperties($path, [
'{DAV:}resourcetype',
'{DAV:}displayname',
'{http://sabredav.org/ns}sync-token',
'{DAV:}sync-token',
'{http://apple.com/ns/ical/}calendar-color',
]);
// Filter out invalid (e.g. deleted) calendars
if (!isset($properties['{DAV:}resourcetype']) || !$properties['{DAV:}resourcetype']->is('{' . CalDAVPlugin::NS_CALDAV . '}calendar')) {
throw new InvalidCalendarException();
}
/**
* @see \Sabre\CalDAV\ICSExportPlugin::generateResponse() implementation reference
*/
$calDataProp = '{' . CalDAVPlugin::NS_CALDAV . '}calendar-data';
$calendarNode = $this->sabreDavServer->tree->getNodeForPath($path);
$nodes = $this->sabreDavServer->getPropertiesIteratorForPath($path, [$calDataProp], 1);
$blobs = [];
foreach ($nodes as $node) {
if (isset($node[200][$calDataProp])) {
$blobs[$node['href']] = $node[200][$calDataProp];
}
}
$mergedCalendar = $this->icsExportPlugin->mergeObjects(
$properties,
$blobs,
);
$problems = $mergedCalendar->validate();
if (!empty($problems)) {
$output->writeln('Skipping calendar "' . $properties['{DAV:}displayname'] . '" containing invalid calendar data');
throw new InvalidCalendarException();
}
return [
'name' => $calendarNode->getName(),
'vCalendar' => $mergedCalendar,
];
}
/**
* @return array<int, array{name: string, vCalendar: VCalendar}>
*
* @throws CalendarMigratorException
*/
private function getCalendarExports(IUser $user, OutputInterface $output): array {
$principalUri = $this->getPrincipalUri($user);
return array_values(array_filter(array_map(
function (ICalendar $calendar) use ($user, $output) {
try {
return $this->getCalendarExportData($user, $calendar, $output);
} catch (InvalidCalendarException $e) {
// Allow this exception as invalid (e.g. deleted) calendars are not to be exported
return null;
}
},
$this->calendarManager->getCalendarsForPrincipal($principalUri),
)));
}
private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string {
$principalUri = $this->getPrincipalUri($user);
try {
$initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX
? $initialCalendarUri
: CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri;
} catch (StringsException $e) {
throw new CalendarMigratorException('Failed to get unique calendar URI', 0, $e);
}
$existingCalendarUris = array_map(
fn (ICalendar $calendar) => $calendar->getUri(),
$this->calendarManager->getCalendarsForPrincipal($principalUri),
);
$calendarUri = $initialCalendarUri;
$acc = 1;
while (in_array($calendarUri, $existingCalendarUris, true)) {
$calendarUri = $initialCalendarUri . "-$acc";
++$acc;
}
return $calendarUri;
}
/**
* {@inheritDoc}
*/
public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
$output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…');
$calendarExports = $this->getCalendarExports($user, $output);
if (empty($calendarExports)) {
$output->writeln('No calendars to export…');
}
/**
* @var string $name
* @var VCalendar $vCalendar
*/
foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) {
// Set filename to sanitized calendar name appended with the date
$filename = preg_replace('/[^a-zA-Z0-9-_ ]/um', '', $name) . '_' . date('Y-m-d') . CalendarMigrator::FILENAME_EXT;
$exportPath = CalendarMigrator::EXPORT_ROOT . $filename;
if ($exportDestination->addFileContents($exportPath, $vCalendar->serialize()) === false) {
throw new CalendarMigratorException('Could not export calendars');
}
}
}
/**
* @return array<string, VTimeZone>
*/
private function getCalendarTimezones(VCalendar $vCalendar): array {
/** @var VTimeZone[] $calendarTimezones */
$calendarTimezones = array_filter(
$vCalendar->getComponents(),
fn ($component) => $component->name === 'VTIMEZONE',
);
/** @var array<string, VTimeZone> $calendarTimezoneMap */
$calendarTimezoneMap = [];
foreach ($calendarTimezones as $vTimeZone) {
$calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone;
}
return $calendarTimezoneMap;
}
/**
* @return VTimeZone[]
*/
private function getTimezonesForComponent(VCalendar $vCalendar, VObjectComponent $component): array {
$componentTimezoneIds = [];
foreach ($component->children() as $child) {
if ($child instanceof DateTime && isset($child->parameters['TZID'])) {
$timezoneId = $child->parameters['TZID']->getValue();
if (!in_array($timezoneId, $componentTimezoneIds, true)) {
$componentTimezoneIds[] = $timezoneId;
}
}
}
$calendarTimezoneMap = $this->getCalendarTimezones($vCalendar);
return array_values(array_filter(array_map(
fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId],
$componentTimezoneIds,
)));
}
private function sanitizeComponent(VObjectComponent $component): VObjectComponent {
// Operate on the component clone to prevent mutation of the original
$component = clone $component;
// Remove RSVP parameters to prevent automatically sending invitation emails to attendees on import
foreach ($component->children() as $child) {
if (
$child->name === 'ATTENDEE'
&& isset($child->parameters['RSVP'])
) {
unset($child->parameters['RSVP']);
}
}
return $component;
}
/**
* @return VObjectComponent[]
*/
private function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array {
$component = $this->sanitizeComponent($component);
/** @var array<int, VTimeZone> $timezoneComponents */
$timezoneComponents = $this->getTimezonesForComponent($vCalendar, $component);
return [
...$timezoneComponents,
$component,
];
}
private function initCalendarObject(): VCalendar {
$vCalendarObject = new VCalendar();
$vCalendarObject->PRODID = '-//IDN nextcloud.com//Migrated calendar//EN';
return $vCalendarObject;
}
private function importCalendarObject(int $calendarId, VCalendar $vCalendarObject, OutputInterface $output): void {
try {
$this->calDavBackend->createCalendarObject(
$calendarId,
UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT,
$vCalendarObject->serialize(),
CalDavBackend::CALENDAR_TYPE_CALENDAR,
);
} catch (Throwable $e) {
// Rollback creation of calendar on error
$output->writeln('Error creating calendar object, rolling back creation of calendar…');
$this->calDavBackend->deleteCalendar($calendarId, true);
}
}
/**
* @throws CalendarMigratorException
*/
private function importCalendar(IUser $user, string $filename, string $initialCalendarUri, VCalendar $vCalendar, OutputInterface $output): void {
$principalUri = $this->getPrincipalUri($user);
$calendarUri = $this->getUniqueCalendarUri($user, $initialCalendarUri);
$calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
'{DAV:}displayname' => isset($vCalendar->{'X-WR-CALNAME'}) ? $vCalendar->{'X-WR-CALNAME'}->getValue() : $this->l10n->t('Migrated calendar (%1$s)', [$filename]),
'{http://apple.com/ns/ical/}calendar-color' => isset($vCalendar->{'X-APPLE-CALENDAR-COLOR'}) ? $vCalendar->{'X-APPLE-CALENDAR-COLOR'}->getValue() : $this->defaults->getColorPrimary(),
'components' => implode(
',',
array_reduce(
$vCalendar->getComponents(),
function (array $componentNames, VObjectComponent $component) {
/** @var array<int, string> $componentNames */
return !in_array($component->name, $componentNames, true)
? [...$componentNames, $component->name]
: $componentNames;
},
[],
)
),
]);
/** @var VObjectComponent[] $calendarComponents */
$calendarComponents = array_values(array_filter(
$vCalendar->getComponents(),
// VTIMEZONE components are handled separately and added to the calendar object only if depended on by the component
fn (VObjectComponent $component) => $component->name !== 'VTIMEZONE',
));
/** @var array<string, VObjectComponent[]> $groupedCalendarComponents */
$groupedCalendarComponents = [];
/** @var VObjectComponent[] $ungroupedCalendarComponents */
$ungroupedCalendarComponents = [];
foreach ($calendarComponents as $component) {
if (isset($component->UID)) {
$uid = $component->UID->getValue();
// Components with the same UID (e.g. recurring events) are grouped together into a single calendar object
if (isset($groupedCalendarComponents[$uid])) {
$groupedCalendarComponents[$uid][] = $component;
} else {
$groupedCalendarComponents[$uid] = [$component];
}
} else {
$ungroupedCalendarComponents[] = $component;
}
}
foreach ($groupedCalendarComponents as $uid => $components) {
// Construct and import a calendar object containing all components of a group
$vCalendarObject = $this->initCalendarObject();
foreach ($components as $component) {
foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
$vCalendarObject->add($component);
}
}
$this->importCalendarObject($calendarId, $vCalendarObject, $output);
}
foreach ($ungroupedCalendarComponents as $component) {
// Construct and import a calendar object for a single component
$vCalendarObject = $this->initCalendarObject();
foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
$vCalendarObject->add($component);
}
$this->importCalendarObject($calendarId, $vCalendarObject, $output);
}
}
/**
* {@inheritDoc}
*
* @throws CalendarMigratorException
*/
public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
if ($importSource->getMigratorVersion(static::class) === null) {
$output->writeln('No version for ' . static::class . ', skipping import…');
return;
}
$output->writeln('Importing calendars from ' . CalendarMigrator::EXPORT_ROOT . '…');
$calendarImports = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT);
if (empty($calendarImports)) {
$output->writeln('No calendars to import…');
}
foreach ($calendarImports as $filename) {
$importPath = CalendarMigrator::EXPORT_ROOT . $filename;
try {
/** @var VCalendar $vCalendar */
$vCalendar = VObjectReader::read(
$importSource->getFileAsStream($importPath),
VObjectReader::OPTION_FORGIVING,
);
} catch (Throwable $e) {
throw new CalendarMigratorException("Failed to read file \"$importPath\"", 0, $e);
}
$problems = $vCalendar->validate();
if (!empty($problems)) {
throw new CalendarMigratorException("Invalid calendar data contained in \"$importPath\"");
}
$splitFilename = explode('_', $filename, 2);
if (count($splitFilename) !== 2) {
throw new CalendarMigratorException("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>_YYYY-MM-DD" . CalendarMigrator::FILENAME_EXT . '"');
}
[$initialCalendarUri, $suffix] = $splitFilename;
$this->importCalendar(
$user,
$filename,
$initialCalendarUri,
$vCalendar,
$output,
);
$vCalendar->destroy();
}
}
}

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\DAV\UserMigration;
use Exception;
class CalendarMigratorException extends Exception {
}

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\DAV\UserMigration;
use Exception;
class InvalidCalendarException extends Exception {
}

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\DAV\Tests\integration\UserMigration;
use function Safe\scandir;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\UserMigration\CalendarMigrator;
use OCP\AppFramework\App;
use OCP\IUserManager;
use Sabre\VObject\Component as VObjectComponent;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Property as VObjectProperty;
use Sabre\VObject\Reader as VObjectReader;
use Sabre\VObject\UUIDUtil;
use Symfony\Component\Console\Output\OutputInterface;
use Test\TestCase;
/**
* @group DB
*/
class CalendarMigratorTest extends TestCase {
private IUserManager $userManager;
private CalendarMigrator $migrator;
private OutputInterface $output;
private const ASSETS_DIR = __DIR__ . '/assets/';
protected function setUp(): void {
$app = new App(Application::APP_ID);
$container = $app->getContainer();
$this->userManager = $container->get(IUserManager::class);
$this->migrator = $container->get(CalendarMigrator::class);
$this->output = $this->createMock(OutputInterface::class);
}
public function dataAssets(): array {
return array_map(
function (string $filename) {
/** @var VCalendar $vCalendar */
$vCalendar = VObjectReader::read(
fopen(self::ASSETS_DIR . $filename, 'r'),
VObjectReader::OPTION_FORGIVING,
);
[$initialCalendarUri, $ext] = explode('.', $filename, 2);
return [UUIDUtil::getUUID(), $filename, $initialCalendarUri, $vCalendar];
},
array_diff(
scandir(self::ASSETS_DIR),
// Exclude current and parent directories
['.', '..'],
),
);
}
private function getProperties(VCalendar $vCalendar): array {
return array_map(
fn (VObjectProperty $property) => $property->serialize(),
array_values(array_filter(
$vCalendar->children(),
fn (mixed $child) => $child instanceof VObjectProperty,
)),
);
}
private function getComponents(VCalendar $vCalendar): array {
return array_map(
// Elements of the serialized blob are sorted
fn (VObjectComponent $component) => $component->serialize(),
$vCalendar->getComponents(),
);
}
private function getSanitizedComponents(VCalendar $vCalendar): array {
return array_map(
// Elements of the serialized blob are sorted
fn (VObjectComponent $component) => $this->invokePrivate($this->migrator, 'sanitizeComponent', [$component])->serialize(),
$vCalendar->getComponents(),
);
}
/**
* @dataProvider dataAssets
*/
public function testImportExportAsset(string $userId, string $filename, string $initialCalendarUri, VCalendar $importCalendar): void {
$user = $this->userManager->createUser($userId, 'topsecretpassword');
$problems = $importCalendar->validate();
$this->assertEmpty($problems);
$this->invokePrivate($this->migrator, 'importCalendar', [$user, $filename, $initialCalendarUri, $importCalendar, $this->output]);
$calendarExports = $this->invokePrivate($this->migrator, 'getCalendarExports', [$user, $this->output]);
$this->assertCount(1, $calendarExports);
/** @var VCalendar $exportCalendar */
['vCalendar' => $exportCalendar] = reset($calendarExports);
$this->assertEqualsCanonicalizing(
$this->getProperties($importCalendar),
$this->getProperties($exportCalendar),
);
$this->assertEqualsCanonicalizing(
// Components are expected to be sanitized on import
$this->getSanitizedComponents($importCalendar),
$this->getComponents($exportCalendar),
);
}
}

@ -0,0 +1,42 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Alarms
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
DTEND;TZID=Europe/Berlin:20160816T100000
TRANSP:OPAQUE
SUMMARY:Test Europe Berlin
DTSTART;TZID=Europe/Berlin:20160816T090000
DTSTAMP:20160809T163632Z
SEQUENCE:0
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;RELATED=START:P1DT9H
END:VALARM
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;VALUE=DATE-TIME:20200306T083000Z
END:VALARM
END:VEVENT
END:VCALENDAR

@ -0,0 +1,39 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Attendees
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
DTEND;TZID=Europe/Berlin:20160816T100000
TRANSP:OPAQUE
SUMMARY:Test Europe Berlin
DTSTART;TZID=Europe/Berlin:20160816T090000
DTSTAMP:20160809T163632Z
SEQUENCE:0
ORGANIZER;CN=John Smith:mailto:jsmith@example.com
ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Henry Cabot:mailto:hcabot@example.com
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="mailto:bob@example.com";PARTSTAT=ACCEPTED;CN=Jane Doe:mailto:jdoe@example.com
ATTENDEE;ROLE=NON-PARTICIPANT;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:hcabot@example.com";CN=The Big Cheese:mailto:iamboss@example.com
END:VEVENT
END:VCALENDAR

@ -0,0 +1,35 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Categories
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
DTEND;TZID=Europe/Berlin:20160816T100000
TRANSP:OPAQUE
SUMMARY:Test Europe Berlin
DTSTART;TZID=Europe/Berlin:20160816T090000
DTSTAMP:20160809T163632Z
SEQUENCE:0
CATEGORIES:BUSINESS,HUMAN RESOURCES
END:VEVENT
END:VCALENDAR

@ -0,0 +1,33 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Complex alarm recurring
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VEVENT
CREATED:20220218T031205Z
DTSTAMP:20220218T031409Z
LAST-MODIFIED:20220218T031409Z
SEQUENCE:2
UID:b78f3a65-413d-4fa7-b125-1232bc6a2c72
DTSTART;VALUE=DATE:20220217
DTEND;VALUE=DATE:20220218
STATUS:TENTATIVE
SUMMARY:Complex recurring event
LOCATION:Antarctica
DESCRIPTION:Event description
CLASS:CONFIDENTIAL
TRANSP:TRANSPARENT
CATEGORIES:Personal,Travel,Special occasion
COLOR:khaki
RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=WE;BYSETPOS=2
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;RELATED=START:-P6DT15H
END:VALARM
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;RELATED=START:-PT15H
END:VALARM
END:VEVENT
END:VCALENDAR

@ -0,0 +1,87 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Complex recurrence
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST
RRULE:FREQ=WEEKLY
DTSTART;TZID=Europe/Berlin:20200301T150000
DTEND;TZID=Europe/Berlin:20200301T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 1
RECURRENCE-ID;TZID=Europe/Berlin:20200308T150000
DTSTART;TZID=Europe/Berlin:20200401T150000
DTEND;TZID=Europe/Berlin:20200401T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 2
RECURRENCE-ID;TZID=Europe/Berlin:20200315T150000
DTSTART;TZID=Europe/Berlin:20201101T150000
DTEND;TZID=Europe/Berlin:20201101T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 3
RECURRENCE-ID;TZID=Europe/Berlin:20200405T150000
DTSTART;TZID=Europe/Berlin:20200406T150000
DTEND;TZID=Europe/Berlin:20200406T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 4
RECURRENCE-ID;TZID=Europe/Berlin:20200412T150000
DTSTART;TZID=Europe/Berlin:20201201T150000
DTEND;TZID=Europe/Berlin:20201201T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 5
RECURRENCE-ID;TZID=Europe/Berlin:20200426T150000
DTSTART;TZID=Europe/Berlin:20200410T150000
DTEND;TZID=Europe/Berlin:20200410T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:INVALID RECURRENCE-ID
RECURRENCE-ID;TZID=Europe/Berlin:20200427T150000
DTSTART;TZID=Europe/Berlin:20200420T150000
DTEND;TZID=Europe/Berlin:20200420T160000
END:VEVENT
END:VCALENDAR

@ -0,0 +1,34 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Personal
X-APPLE-CALENDAR-COLOR:#f264ab
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
DTEND;TZID=Europe/Berlin:20160816T100000
TRANSP:OPAQUE
SUMMARY:Test Europe Berlin
DTSTART;TZID=Europe/Berlin:20160816T090000
DTSTAMP:20160809T163632Z
SEQUENCE:0
END:VEVENT
END:VCALENDAR

@ -0,0 +1,74 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Multiple and recurring
X-APPLE-CALENDAR-COLOR:#795AAB
BEGIN:VEVENT
CREATED:20220218T044833Z
DTSTAMP:20220218T044837Z
LAST-MODIFIED:20220218T044837Z
SEQUENCE:2
UID:dc343863-b57c-43a5-9ba4-19ae2740cd7e
DTSTART;VALUE=DATE:20220607
DTEND;VALUE=DATE:20220608
STATUS:CONFIRMED
SUMMARY:Event 4
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044806Z
DTSTAMP:20220218T044809Z
LAST-MODIFIED:20220218T044809Z
SEQUENCE:2
UID:ae28b642-7e11-4e16-818a-06c89ae74f44
DTSTART;VALUE=DATE:20220218
DTEND;VALUE=DATE:20220219
STATUS:CONFIRMED
SUMMARY:Event 1
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044820Z
DTSTAMP:20220218T044827Z
LAST-MODIFIED:20220218T044827Z
SEQUENCE:2
UID:5edfb90e-44b3-47c6-863b-f632327f46f0
DTSTART;VALUE=DATE:20220518
DTEND;VALUE=DATE:20220519
STATUS:CONFIRMED
SUMMARY:Event 3
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044810Z
DTSTAMP:20220218T044814Z
LAST-MODIFIED:20220218T044814Z
SEQUENCE:2
UID:9789f684-1cf9-4ee7-90cb-54cdec6a6f03
DTSTART;VALUE=DATE:20220223
DTEND;VALUE=DATE:20220224
STATUS:CONFIRMED
SUMMARY:Event 2
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044932Z
DTSTAMP:20220218T044945Z
LAST-MODIFIED:20220218T044945Z
SEQUENCE:2
UID:d5bdaf0e-d6c7-4e30-a730-04928976a1d2
DTSTART;VALUE=DATE:20221102
DTEND;VALUE=DATE:20221103
STATUS:CONFIRMED
SUMMARY:Recurring event
RRULE:FREQ=WEEKLY;BYDAY=WE
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044915Z
DTSTAMP:20220218T044918Z
LAST-MODIFIED:20220218T044918Z
SEQUENCE:2
UID:11c3d9fd-fb54-4384-ab68-40b5d6acef6f
DTSTART;VALUE=DATE:20221010
DTEND;VALUE=DATE:20221011
STATUS:CONFIRMED
SUMMARY:Event 5
END:VEVENT
END:VCALENDAR

@ -0,0 +1,62 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Multiple
X-APPLE-CALENDAR-COLOR:#795AAB
BEGIN:VEVENT
CREATED:20220218T044833Z
DTSTAMP:20220218T044837Z
LAST-MODIFIED:20220218T044837Z
SEQUENCE:2
UID:dc343863-b57c-43a5-9ba4-19ae2740cd7e
DTSTART;VALUE=DATE:20220607
DTEND;VALUE=DATE:20220608
STATUS:CONFIRMED
SUMMARY:Event 4
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044806Z
DTSTAMP:20220218T044809Z
LAST-MODIFIED:20220218T044809Z
SEQUENCE:2
UID:ae28b642-7e11-4e16-818a-06c89ae74f44
DTSTART;VALUE=DATE:20220218
DTEND;VALUE=DATE:20220219
STATUS:CONFIRMED
SUMMARY:Event 1
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044820Z
DTSTAMP:20220218T044827Z
LAST-MODIFIED:20220218T044827Z
SEQUENCE:2
UID:5edfb90e-44b3-47c6-863b-f632327f46f0
DTSTART;VALUE=DATE:20220518
DTEND;VALUE=DATE:20220519
STATUS:CONFIRMED
SUMMARY:Event 3
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044810Z
DTSTAMP:20220218T044814Z
LAST-MODIFIED:20220218T044814Z
SEQUENCE:2
UID:9789f684-1cf9-4ee7-90cb-54cdec6a6f03
DTSTART;VALUE=DATE:20220223
DTEND;VALUE=DATE:20220224
STATUS:CONFIRMED
SUMMARY:Event 2
END:VEVENT
BEGIN:VEVENT
CREATED:20220218T044915Z
DTSTAMP:20220218T044918Z
LAST-MODIFIED:20220218T044918Z
SEQUENCE:2
UID:11c3d9fd-fb54-4384-ab68-40b5d6acef6f
DTSTART;VALUE=DATE:20221010
DTEND;VALUE=DATE:20221011
STATUS:CONFIRMED
SUMMARY:Event 5
END:VEVENT
END:VCALENDAR

@ -0,0 +1,87 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Recurring
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST
RRULE:FREQ=WEEKLY
DTSTART;TZID=Europe/Berlin:20200301T150000
DTEND;TZID=Europe/Berlin:20200301T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 1
RECURRENCE-ID;TZID=Europe/Berlin:20200308T150000
DTSTART;TZID=Europe/Berlin:20200401T150000
DTEND;TZID=Europe/Berlin:20200401T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 2
RECURRENCE-ID;TZID=Europe/Berlin:20200315T150000
DTSTART;TZID=Europe/Berlin:20201101T150000
DTEND;TZID=Europe/Berlin:20201101T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 3
RECURRENCE-ID;TZID=Europe/Berlin:20200405T150000
DTSTART;TZID=Europe/Berlin:20200406T150000
DTEND;TZID=Europe/Berlin:20200406T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 4
RECURRENCE-ID;TZID=Europe/Berlin:20200412T150000
DTSTART;TZID=Europe/Berlin:20201201T150000
DTEND;TZID=Europe/Berlin:20201201T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:TEST EX 5
RECURRENCE-ID;TZID=Europe/Berlin:20200426T150000
DTSTART;TZID=Europe/Berlin:20200410T150000
DTEND;TZID=Europe/Berlin:20200410T160000
END:VEVENT
BEGIN:VEVENT
CREATED:20160809T163629Z
DTSTAMP:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
SUMMARY:INVALID RECURRENCE-ID
RECURRENCE-ID;TZID=Europe/Berlin:20200427T150000
DTSTART;TZID=Europe/Berlin:20200420T150000
DTEND;TZID=Europe/Berlin:20200420T160000
END:VEVENT
END:VCALENDAR

@ -0,0 +1,34 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Timed
X-APPLE-CALENDAR-COLOR:#0082c9
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
DTSTART:19810329T020000
TZNAME:GMT+2
TZOFFSETTO:+0200
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
DTSTART:19961027T030000
TZNAME:GMT+1
TZOFFSETTO:+0100
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
CREATED:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
DTEND;TZID=Europe/Berlin:20160816T100000
TRANSP:OPAQUE
SUMMARY:Test Europe Berlin
DTSTART;TZID=Europe/Berlin:20160816T090000
DTSTAMP:20160809T163632Z
SEQUENCE:0
END:VEVENT
END:VCALENDAR

@ -0,0 +1,41 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Journal Todo Event
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VJOURNAL
UID:19970901T130000Z-123405@example.com
DTSTAMP:19970901T130000Z
DTSTART;VALUE=DATE:19970317
SUMMARY:Staff meeting minutes
DESCRIPTION:1. Staff meeting: Participants include Joe\,
Lisa\, and Bob. Aurora project plans were reviewed.
There is currently no budget reserves for this project.
Lisa will escalate to management. Next meeting on Tuesday.\n
2. Telephone Conference: ABC Corp. sales representative
called to discuss new printer. Promised to get us a demo by
Friday.\n3. Henry Miller (Handsoff Insurance): Car was
totaled by tree. Is looking into a loaner car. 555-2323
(tel).
END:VJOURNAL
BEGIN:VTODO
UID:20070313T123432Z-456553@example.com
DTSTAMP:20070313T123432Z
DUE;VALUE=DATE:20070501
SUMMARY:Submit Quebec Income Tax Return for 2006
CLASS:CONFIDENTIAL
CATEGORIES:FAMILY,FINANCE
STATUS:NEEDS-ACTION
END:VTODO
BEGIN:VEVENT
CREATED:20160809T163629Z
UID:0AD16F58-01B3-463B-A215-FD09FC729A02
DTEND:20160816T100000
TRANSP:OPAQUE
SUMMARY:Test Event
DTSTART:20160816T090000
DTSTAMP:20160809T163632Z
SEQUENCE:0
END:VEVENT
END:VCALENDAR

@ -0,0 +1,22 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Journal
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VJOURNAL
UID:19970901T130000Z-123405@example.com
DTSTAMP:19970901T130000Z
DTSTART;VALUE=DATE:19970317
SUMMARY:Staff meeting minutes
DESCRIPTION:1. Staff meeting: Participants include Joe\,
Lisa\, and Bob. Aurora project plans were reviewed.
There is currently no budget reserves for this project.
Lisa will escalate to management. Next meeting on Tuesday.\n
2. Telephone Conference: ABC Corp. sales representative
called to discuss new printer. Promised to get us a demo by
Friday.\n3. Henry Miller (Handsoff Insurance): Car was
totaled by tree. Is looking into a loaner car. 555-2323
(tel).
END:VJOURNAL
END:VCALENDAR

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SabreDAV//SabreDAV//EN
CALSCALE:GREGORIAN
X-WR-CALNAME:Todo
X-APPLE-CALENDAR-COLOR:#0082c9
BEGIN:VTODO
UID:20070313T123432Z-456553@example.com
DTSTAMP:20070313T123432Z
DUE;VALUE=DATE:20070501
SUMMARY:Submit Quebec Income Tax Return for 2006
CLASS:CONFIDENTIAL
CATEGORIES:FAMILY,FINANCE
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR

@ -39,6 +39,11 @@ interface ICalendar {
*/
public function getKey();
/**
* @since 24.0.0
*/
public function getUri(): string;
/**
* In comparison to getKey() this function returns a human readable (maybe translated) name
* @return null|string