From d27c7f95f1d37d0b133d52571b152e5e9a3d9068 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Mon, 15 Dec 2025 23:13:41 -0500 Subject: [PATCH] feat: improve calendar migrator Signed-off-by: SebastianKrupinski --- .../lib/UserMigration/CalendarMigrator.php | 620 ++++++++---------- .../UserMigration/CalendarMigratorTest.php | 444 +++++++++++-- 2 files changed, 645 insertions(+), 419 deletions(-) diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 73e9c3754902a..d920ebab7924a 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -11,193 +11,92 @@ 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 OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Export\ExportService; +use OCA\DAV\CalDAV\Import\ImportService; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\CalendarImportOptions; use OCP\Calendar\IManager as ICalendarManager; use OCP\Defaults; use OCP\IL10N; +use OCP\ITempManager; use OCP\IUser; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use OCP\UserMigration\IMigrator; use OCP\UserMigration\ISizeEstimationMigrator; 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 Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Throwable; -use function substr; class CalendarMigrator implements IMigrator, ISizeEstimationMigrator { use TMigratorBasicVersionHandling; - 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( - private CalDavBackend $calDavBackend, - private ICalendarManager $calendarManager, - private ICSExportPlugin $icsExportPlugin, - private Defaults $defaults, - private IL10N $l10n, + private readonly CalDavBackend $calDavBackend, + private readonly ICalendarManager $calendarManager, + private readonly Defaults $defaults, + private readonly IL10N $l10n, + private readonly ExportService $exportService, + private readonly ImportService $importService, + private readonly ITempManager $tempManager, ) { - $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(); + $this->version = 2; } /** - * @return array{name: string, vCalendar: VCalendar} - * - * @throws CalendarMigratorException - * @throws InvalidCalendarException + * {@inheritDoc} */ - private function getCalendarExportData(IUser $user, ICalendar $calendar, OutputInterface $output): array { - $userId = $user->getUID(); - $uri = $calendar->getUri(); - $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, - ]; + public function getId(): string { + return 'calendar'; } /** - * @return array - * - * @throws CalendarMigratorException + * {@inheritDoc} */ - 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), - ))); + public function getDisplayName(): string { + return $this->l10n->t('Calendar'); } /** - * @throws InvalidCalendarException + * {@inheritDoc} */ - private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string { - $principalUri = $this->getPrincipalUri($user); - - $initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX - ? $initialCalendarUri - : CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri; - - if ($initialCalendarUri === '') { - throw new InvalidCalendarException(); - } - - $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; + public function getDescription(): string { + return $this->l10n->t('Calendars including events, details and attendees'); } /** * {@inheritDoc} */ public function getEstimatedExportSize(IUser $user): int|float { - $calendarExports = $this->getCalendarExports($user, new NullOutput()); - $calendarCount = count($calendarExports); + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri); - // 150B for top-level properties - $size = ($calendarCount * 150) / 1024; + $calendarCount = 0; + $totalSize = 0; - $componentCount = array_sum(array_map( - function (array $data): int { - /** @var VCalendar $vCalendar */ - $vCalendar = $data['vCalendar']; - return count($vCalendar->getComponents()); - }, - $calendarExports, - )); + foreach ($calendars as $calendar) { + if (!$calendar instanceof CalendarImpl) { + continue; + } + if ($calendar->isShared()) { + continue; + } + $calendarCount++; + // Note: 'uid' is required because getLimitedCalendarObjects uses it as the array key + $objects = $this->calDavBackend->getLimitedCalendarObjects((int)$calendar->getKey(), CalDavBackend::CALENDAR_TYPE_CALENDAR, ['uid', 'size']); + foreach ($objects as $object) { + $totalSize += (int)($object['size'] ?? 0); + } + } - // 450B for each component (events, todos, alarms, etc.) - $size += ($componentCount * 450) / 1024; + // 150B for meta file per calendar + total calendar data size + $size = ($calendarCount * 150 + $totalSize) / 1024; return ceil($size); } @@ -208,23 +107,54 @@ function (array $data): int { public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { $output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…'); - $calendarExports = $this->getCalendarExports($user, $output); + $calendarExports = $this->calendarManager->getCalendarsForPrincipal(self::USERS_URI_ROOT . $user->getUID()); if (empty($calendarExports)) { $output->writeln('No calendars to export…'); } try { - /** - * @var string $name - * @var VCalendar $vCalendar - */ - foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) { - // Set filename to sanitized calendar name - $filename = preg_replace('/[^a-z0-9-_]/iu', '', $name) . CalendarMigrator::FILENAME_EXT; - $exportPath = CalendarMigrator::EXPORT_ROOT . $filename; - - $exportDestination->addFileContents($exportPath, $vCalendar->serialize()); + /** @var CalendarImpl $calendar */ + foreach ($calendarExports as $calendar) { + $output->writeln('Exporting calendar "' . $calendar->getUri() . '"'); + + if (!$calendar instanceof CalendarImpl) { + $output->writeln('Skipping unsupported calendar type for "' . $calendar->getUri() . '"'); + continue; + } + + if ($calendar->isShared()) { + $output->writeln('Skipping shared calendar "' . $calendar->getUri() . '"'); + continue; + } + + // construct archive paths + $filename = preg_replace('/[^a-z0-9-_]/iu', '', $calendar->getUri()); + $exportMetaPath = CalendarMigrator::EXPORT_ROOT . $filename . '.meta'; + $exportDataPath = CalendarMigrator::EXPORT_ROOT . $filename . '.data'; + + // add calendar meta to the export archive + $exportDestination->addFileContents($exportMetaPath, json_encode([ + 'format' => 'ical', + 'uri' => $calendar->getUri(), + 'label' => $calendar->getDisplayName(), + 'color' => $calendar->getDisplayColor(), + 'timezone' => $calendar->getSchedulingTimezone(), + ], JSON_THROW_ON_ERROR)); + + // export calendar data to a temporary file + $options = new CalendarExportOptions(); + $options->setFormat('ical'); + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + foreach ($this->exportService->export($calendar, $options) as $chunk) { + fwrite($tempFile, $chunk); + } + + // add the temporary file to the export archive + rewind($tempFile); + $exportDestination->addFileAsStream($exportDataPath, $tempFile); + fclose($tempFile); } } catch (Throwable $e) { throw new CalendarMigratorException('Could not export calendars', 0, $e); @@ -232,170 +162,104 @@ public function export(IUser $user, IExportDestination $exportDestination, Outpu } /** - * @return array + * {@inheritDoc} + * + * @throws CalendarMigratorException */ - private function getCalendarTimezones(VCalendar $vCalendar): array { - /** @var VTimeZone[] $calendarTimezones */ - $calendarTimezones = array_filter( - $vCalendar->getComponents(), - fn ($component) => $component->name === 'VTIMEZONE', - ); - - /** @var array $calendarTimezoneMap */ - $calendarTimezoneMap = []; - foreach ($calendarTimezones as $vTimeZone) { - $calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone; + public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $migratorVersion = $importSource->getMigratorVersion($this->getId()); + + if ($migratorVersion === null) { + $output->writeln('No version for ' . static::class . ', skipping import…'); + return; } - return $calendarTimezoneMap; + $output->writeln('Importing calendars from ' . CalendarMigrator::EXPORT_ROOT . '…'); + + match ($migratorVersion) { + 1 => $this->importV1($user, $importSource, $output), + 2 => $this->importV2($user, $importSource, $output), + default => throw new CalendarMigratorException('Unsupported migrator version ' . $migratorVersion . ' for ' . static::class), + }; } /** - * @return VTimeZone[] + * {@inheritDoc} + * + * @throws CalendarMigratorException */ - 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; - } - } + public function importV2(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $files = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT); + if (empty($files)) { + $output->writeln('No calendars to import…'); } - $calendarTimezoneMap = $this->getCalendarTimezones($vCalendar); - - return array_values(array_filter(array_map( - fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId], - $componentTimezoneIds, - ))); - } + $principalUri = self::USERS_URI_ROOT . $user->getUID(); - 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']); + foreach ($files as $filename) { + // Only process .meta files + if (!str_ends_with($filename, '.meta')) { + continue; } - } - - return $component; - } - - /** - * @return VObjectComponent[] - */ - private function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array { - $component = $this->sanitizeComponent($component); - /** @var array $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; - } - - /** - * @throws InvalidCalendarException - */ - private function importCalendarObject(int $calendarId, VCalendar $vCalendarObject, string $filename, OutputInterface $output): void { - try { - $this->calDavBackend->createCalendarObject( - $calendarId, - UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT, - $vCalendarObject->serialize(), - CalDavBackend::CALENDAR_TYPE_CALENDAR, - ); - } catch (Throwable $e) { - $output->writeln("Error creating calendar object, rolling back creation of \"$filename\" calendar…"); - $this->calDavBackend->deleteCalendar($calendarId, true); - throw new InvalidCalendarException(); - } - } + // construct archive paths + $importMetaPath = CalendarMigrator::EXPORT_ROOT . $filename; + $importDataPath = CalendarMigrator::EXPORT_ROOT . substr($filename, 0, -5) . '.data'; - /** - * @throws InvalidCalendarException - */ - 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 $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 $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; + try { + // read calendar meta from the import archive + $calendarMeta = json_decode($importSource->getFileContents($importMetaPath), true, 512, JSON_THROW_ON_ERROR); + $migratedCalendarUri = self::MIGRATED_URI_PREFIX . $calendarMeta['uri']; + // check if a calendar with this URI already exists + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]); + if (empty($calendars)) { + $output->writeln("Creating calendar \"$migratedCalendarUri\""); + // create the calendar + $this->calDavBackend->createCalendar($principalUri, $migratedCalendarUri, [ + '{DAV:}displayname' => $calendarMeta['label'] ?? $this->l10n->t('Migrated calendar (%1$s)', [$calendarMeta['uri']]), + '{http://apple.com/ns/ical/}calendar-color' => $calendarMeta['color'] ?? $this->defaults->getColorPrimary(), + '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => $calendarMeta['timezone'] ?? null, + ]); + // retrieve the created calendar + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]); + if (empty($calendars) || !($calendars[0] instanceof CalendarImpl)) { + $output->writeln("Failed to retrieve created calendar \"$migratedCalendarUri\", skipping import…"); + continue; + } } else { - $groupedCalendarComponents[$uid] = [$component]; + $output->writeln("Using existing calendar \"$migratedCalendarUri\""); } - } else { - $ungroupedCalendarComponents[] = $component; - } - } + $calendar = $calendars[0]; - 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); + // copy import stream to temporary file as the source stream is not rewindable + $importStream = $importSource->getFileAsStream($importDataPath); + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + stream_copy_to_stream($importStream, $tempFile); + rewind($tempFile); + + // import calendar data + try { + $options = new CalendarImportOptions(); + $options->setFormat($calendarMeta['format'] ?? 'ical'); + $options->setErrors(0); + $options->setValidate(1); + $options->setSupersede(true); + + $outcome = $this->importService->import( + $tempFile, + $calendar, + $options + ); + } finally { + fclose($tempFile); } - } - $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $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->importSummary($calendarMeta['label'] ?? $calendarMeta['uri'], $outcome, $output); + } catch (Throwable $e) { + $output->writeln("Failed to import calendar \"$filename\", skipping…"); + continue; } - $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output); } } @@ -404,79 +268,117 @@ function (array $componentNames, VObjectComponent $component) { * * @throws CalendarMigratorException */ - public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { - if ($importSource->getMigratorVersion($this->getId()) === 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)) { + public function importV1(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $files = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT); + if (empty($files)) { $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) { - $output->writeln("Failed to read file \"$importPath\", skipping…"); - continue; - } + $principalUri = self::USERS_URI_ROOT . $user->getUID(); - $problems = $vCalendar->validate(); - if (!empty($problems)) { - $output->writeln("Invalid calendar data contained in \"$importPath\", skipping…"); + foreach ($files as $filename) { + // Only process .ics files + if (!str_ends_with($filename, '.ics')) { continue; } - $splitFilename = explode('.', $filename, 2); - if (count($splitFilename) !== 2) { - $output->writeln("Invalid filename \"$filename\", expected filename of the format \"" . CalendarMigrator::FILENAME_EXT . '", skipping…'); - continue; - } - [$initialCalendarUri, $ext] = $splitFilename; + // construct archive path + $importDataPath = CalendarMigrator::EXPORT_ROOT . $filename; try { - $this->importCalendar( - $user, - $filename, - $initialCalendarUri, - $vCalendar, - $output, - ); - } catch (InvalidCalendarException $e) { - // Allow this exception to skip a failed import - } finally { - $vCalendar->destroy(); + $calendarUri = substr($filename, 0, -4); + $migratedCalendarUri = self::MIGRATED_URI_PREFIX . $calendarUri; + + // copy import stream to temporary file as the source stream is not rewindable + $importStream = $importSource->getFileAsStream($importDataPath); + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + stream_copy_to_stream($importStream, $tempFile); + rewind($tempFile); + + // check if a calendar with this URI already exists + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]); + if (empty($calendars)) { + $output->writeln("Creating calendar \"$migratedCalendarUri\""); + // extract calendar properties from the ICS header without full parsing + $calendarName = null; + $calendarColor = null; + $headerLines = 0; + while (($line = fgets($tempFile)) !== false && $headerLines < 50) { + $headerLines++; + $line = trim($line); + if (str_starts_with($line, 'X-WR-CALNAME:')) { + $calendarName = substr($line, 13); + } elseif (str_starts_with($line, 'X-APPLE-CALENDAR-COLOR:')) { + $calendarColor = substr($line, 23); + } + // stop parsing header once we hit the first component + if (str_starts_with($line, 'BEGIN:VEVENT') + || str_starts_with($line, 'BEGIN:VTODO') + || str_starts_with($line, 'BEGIN:VJOURNAL')) { + break; + } + } + rewind($tempFile); + // create the calendar + $this->calDavBackend->createCalendar($principalUri, $migratedCalendarUri, [ + '{DAV:}displayname' => $calendarName ?? $this->l10n->t('Migrated calendar (%1$s)', [$calendarUri]), + '{http://apple.com/ns/ical/}calendar-color' => $calendarColor ?? $this->defaults->getColorPrimary(), + ]); + // retrieve the created calendar + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri, [$migratedCalendarUri]); + if (empty($calendars) || !($calendars[0] instanceof CalendarImpl)) { + $output->writeln("Failed to retrieve created calendar \"$migratedCalendarUri\", skipping import…"); + fclose($tempFile); + continue; + } + } else { + $output->writeln("Using existing calendar \"$migratedCalendarUri\""); + } + $calendar = $calendars[0]; + + // import calendar data + $options = new CalendarImportOptions(); + $options->setFormat('ical'); + $options->setErrors(0); + $options->setValidate(1); + $options->setSupersede(true); + + try { + $outcome = $this->importService->import( + $tempFile, + $calendar, + $options + ); + } finally { + fclose($tempFile); + } + + $this->importSummary($calendarName ?? $calendarUri, $outcome, $output); + } catch (Throwable $e) { + $output->writeln("Failed to import calendar \"$filename\", skipping…"); + continue; } } } - /** - * {@inheritDoc} - */ - public function getId(): string { - return 'calendar'; - } - /** - * {@inheritDoc} - */ - public function getDisplayName(): string { - return $this->l10n->t('Calendar'); - } + private function importSummary(string $label, array $outcome, OutputInterface $output): void { + $created = 0; + $updated = 0; + $skipped = 0; + $errors = 0; + + foreach ($outcome as $result) { + match ($result['outcome'] ?? null) { + 'created' => $created++, + 'updated' => $updated++, + 'exists' => $skipped++, + 'error' => $errors++, + default => null, + }; + } - /** - * {@inheritDoc} - */ - public function getDescription(): string { - return $this->l10n->t('Calendars including events, details and attendees'); + $output->writeln(" \"$label\": $created created, $updated updated, $skipped skipped, $errors errors"); } } diff --git a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php index ac28793303783..fa41d2e32d0a6 100644 --- a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php +++ b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php @@ -10,107 +10,431 @@ namespace OCA\DAV\Tests\integration\UserMigration; use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\CalendarImpl; use OCA\DAV\UserMigration\CalendarMigrator; use OCP\AppFramework\App; +use OCP\Calendar\IManager as ICalendarManager; +use OCP\IUser; 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 OCP\UserMigration\IExportDestination; +use OCP\UserMigration\IImportSource; use Sabre\VObject\UUIDUtil; use Symfony\Component\Console\Output\OutputInterface; use Test\TestCase; -use function scandir; #[\PHPUnit\Framework\Attributes\Group('DB')] class CalendarMigratorTest extends TestCase { private IUserManager $userManager; - + private ICalendarManager $calendarManager; + private CalDavBackend $calDavBackend; private CalendarMigrator $migrator; - private OutputInterface $output; private const ASSETS_DIR = __DIR__ . '/assets/calendars/'; + private const USERS_URI_ROOT = 'principals/users/'; protected function setUp(): void { + parent::setUp(); + $app = new App(Application::APP_ID); $container = $app->getContainer(); $this->userManager = $container->get(IUserManager::class); + $this->calendarManager = $container->get(ICalendarManager::class); + $this->calDavBackend = $container->get(CalDavBackend::class); $this->migrator = $container->get(CalendarMigrator::class); $this->output = $this->createMock(OutputInterface::class); } - public static 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 - ['.', '..'], - ), - ); + protected function tearDown(): void { + parent::tearDown(); } - private function getProperties(VCalendar $vCalendar): array { - return array_map( - fn (VObjectProperty $property) => $property->serialize(), - array_values(array_filter( - $vCalendar->children(), - fn ($child) => $child instanceof VObjectProperty, - )), - ); + private function createTestUser(): IUser { + $userId = UUIDUtil::getUUID(); + return $this->userManager->createUser($userId, 'topsecretpassword'); } - 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 deleteUser(IUser $user): void { + $user->delete(); } - private function getSanitizedComponents(VCalendar $vCalendar): array { + private function getCalendarsForUser(IUser $user): array { + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri); + return array_filter($calendars, fn ($c) => $c instanceof CalendarImpl && !$c->isShared()); + } + + public static function dataAssets(): array { + $files = scandir(self::ASSETS_DIR); + if ($files === false) { + return []; + } + $files = array_values(array_diff($files, ['.', '..'])); return array_map( - // Elements of the serialized blob are sorted - fn (VObjectComponent $component) => $this->invokePrivate($this->migrator, 'sanitizeComponent', [$component])->serialize(), - $vCalendar->getComponents(), + fn (string $filename) => [$filename], + $files, ); } #[\PHPUnit\Framework\Attributes\DataProvider('dataAssets')] - public function testImportExportAsset(string $userId, string $filename, string $initialCalendarUri, VCalendar $importCalendar): void { - $user = $this->userManager->createUser($userId, 'topsecretpassword'); + public function testImportV1(string $filename): void { + $user = $this->createTestUser(); - $problems = $importCalendar->validate(); - $this->assertEmpty($problems); + try { + // Setup import source mock for V1 format (.ics files) + $importSource = $this->createMock(IImportSource::class); - $this->invokePrivate($this->migrator, 'importCalendar', [$user, $filename, $initialCalendarUri, $importCalendar, $this->output]); + $icsContent = file_get_contents(self::ASSETS_DIR . $filename); + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(1); - $calendarExports = $this->invokePrivate($this->migrator, 'getCalendarExports', [$user, $this->output]); - $this->assertCount(1, $calendarExports); + $importSource->method('getFolderListing') + ->with('dav/calendars/') + ->willReturn([$filename]); - /** @var VCalendar $exportCalendar */ - ['vCalendar' => $exportCalendar] = reset($calendarExports); + $importSource->method('getFileAsStream') + ->willReturnCallback(function (string $path) use ($filename, $icsContent) { + if ($path === 'dav/calendars/' . $filename) { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $icsContent); + rewind($stream); + return $stream; + } + throw new \Exception("Unexpected path: $path"); + }); - $this->assertEqualsCanonicalizing( - $this->getProperties($importCalendar), - $this->getProperties($exportCalendar), - ); + // Import the calendar + $this->migrator->import($user, $importSource, $this->output); - $this->assertEqualsCanonicalizing( - // Components are expected to be sanitized on import - $this->getSanitizedComponents($importCalendar), - $this->getComponents($exportCalendar), - ); + // Verify calendar was created + $calendars = $this->getCalendarsForUser($user); + $this->assertCount(1, $calendars, 'Expected one calendar to be created'); + + // Verify the calendar URI has the migrated prefix + $calendar = reset($calendars); + $expectedUri = 'migrated-' . substr($filename, 0, -4); + $this->assertEquals($expectedUri, $calendar->getUri()); + + // Verify calendar has objects + $objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey()); + $this->assertNotEmpty($objects, 'Expected calendar to have objects'); + } finally { + $this->deleteUser($user); + } + } + + #[\PHPUnit\Framework\Attributes\DataProvider('dataAssets')] + public function testImportV2(string $filename): void { + $user = $this->createTestUser(); + + try { + // Setup import source mock for V2 format (.meta + .data files) + $importSource = $this->createMock(IImportSource::class); + + $icsContent = file_get_contents(self::ASSETS_DIR . $filename); + $calendarUri = substr($filename, 0, -4); + + // Extract calendar name and color from ICS for meta + $calendarName = null; + $calendarColor = null; + $lines = explode("\n", $icsContent); + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, 'X-WR-CALNAME:')) { + $calendarName = substr($line, 13); + } elseif (str_starts_with($line, 'X-APPLE-CALENDAR-COLOR:')) { + $calendarColor = substr($line, 23); + } + if (str_starts_with($line, 'BEGIN:VEVENT') + || str_starts_with($line, 'BEGIN:VTODO') + || str_starts_with($line, 'BEGIN:VJOURNAL')) { + break; + } + } + + $metaContent = json_encode([ + 'format' => 'ical', + 'uri' => $calendarUri, + 'label' => $calendarName ?? $calendarUri, + 'color' => $calendarColor ?? '#0082c9', + 'timezone' => null, + ]); + + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); + + $importSource->method('getFolderListing') + ->with('dav/calendars/') + ->willReturn([$calendarUri . '.meta', $calendarUri . '.data']); + + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($calendarUri, $metaContent) { + if ($path === 'dav/calendars/' . $calendarUri . '.meta') { + return $metaContent; + } + throw new \Exception("Unexpected path: $path"); + }); + + $importSource->method('getFileAsStream') + ->willReturnCallback(function (string $path) use ($calendarUri, $icsContent) { + if ($path === 'dav/calendars/' . $calendarUri . '.data') { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $icsContent); + rewind($stream); + return $stream; + } + throw new \Exception("Unexpected path: $path"); + }); + + // Import the calendar + $this->migrator->import($user, $importSource, $this->output); + + // Verify calendar was created + $calendars = $this->getCalendarsForUser($user); + $this->assertCount(1, $calendars, 'Expected one calendar to be created'); + + // Verify the calendar URI has the migrated prefix + $calendar = reset($calendars); + $expectedUri = 'migrated-' . $calendarUri; + $this->assertEquals($expectedUri, $calendar->getUri()); + + // Verify calendar display name + if ($calendarName !== null) { + $this->assertEquals($calendarName, $calendar->getDisplayName()); + } + + // Verify calendar has objects + $objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey()); + $this->assertNotEmpty($objects, 'Expected calendar to have objects'); + } finally { + $this->deleteUser($user); + } + } + + public function testExport(): void { + $user = $this->createTestUser(); + + try { + // Create a calendar to export + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $calendarUri = 'test-export-calendar'; + $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [ + '{DAV:}displayname' => 'Test Export Calendar', + '{http://apple.com/ns/ical/}calendar-color' => '#ff0000', + ]); + + // Add an event to the calendar + $icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics'); + $this->calDavBackend->createCalendarObject($calendarId, 'test-event.ics', $icsContent); + + // Setup export destination mock + $exportDestination = $this->createMock(IExportDestination::class); + + $exportedMeta = null; + $exportedData = null; + + $exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $content) use (&$exportedMeta) { + if (str_ends_with($path, '.meta')) { + $exportedMeta = json_decode($content, true); + } + }); + + $exportDestination->method('addFileAsStream') + ->willReturnCallback(function (string $path, $stream) use (&$exportedData) { + if (str_ends_with($path, '.data')) { + $exportedData = stream_get_contents($stream); + } + }); + + // Export the calendar + $this->migrator->export($user, $exportDestination, $this->output); + + // Verify meta was exported + $this->assertNotNull($exportedMeta, 'Expected meta to be exported'); + $this->assertEquals('ical', $exportedMeta['format']); + $this->assertEquals($calendarUri, $exportedMeta['uri']); + $this->assertEquals('Test Export Calendar', $exportedMeta['label']); + $this->assertEquals('#ff0000', $exportedMeta['color']); + + // Verify data was exported + $this->assertNotNull($exportedData, 'Expected data to be exported'); + $this->assertIsString($exportedData); + /** @var string $exportedData */ + $this->assertStringContainsString('BEGIN:VCALENDAR', $exportedData); + $this->assertStringContainsString('BEGIN:VEVENT', $exportedData); + } finally { + $this->deleteUser($user); + } + } + + public function testExportImportRoundTrip(): void { + $user = $this->createTestUser(); + + try { + // Create a calendar with some events + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $calendarUri = 'roundtrip-calendar'; + $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [ + '{DAV:}displayname' => 'Round Trip Calendar', + '{http://apple.com/ns/ical/}calendar-color' => '#00ff00', + ]); + + // Add events to the calendar + $icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics'); + $this->calDavBackend->createCalendarObject($calendarId, 'event1.ics', $icsContent); + + // Capture exported data + $exportedFiles = []; + + $exportDestination = $this->createMock(IExportDestination::class); + $exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $content) use (&$exportedFiles) { + $exportedFiles[$path] = $content; + }); + $exportDestination->method('addFileAsStream') + ->willReturnCallback(function (string $path, $stream) use (&$exportedFiles) { + $exportedFiles[$path] = stream_get_contents($stream); + }); + + // Export + $this->migrator->export($user, $exportDestination, $this->output); + + // Delete the original calendar + $this->calDavBackend->deleteCalendar($calendarId, true); + + // Verify calendar is gone + $calendars = $this->getCalendarsForUser($user); + $this->assertEmpty($calendars, 'Calendar should be deleted'); + + // Setup import source from exported data + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); + + $importSource->method('getFolderListing') + ->with('dav/calendars/') + ->willReturn(array_map(fn ($p) => basename($p), array_keys($exportedFiles))); + + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($exportedFiles) { + if (isset($exportedFiles[$path])) { + return $exportedFiles[$path]; + } + throw new \Exception("File not found: $path"); + }); + + $importSource->method('getFileAsStream') + ->willReturnCallback(function (string $path) use ($exportedFiles) { + if (isset($exportedFiles[$path])) { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $exportedFiles[$path]); + rewind($stream); + return $stream; + } + throw new \Exception("File not found: $path"); + }); + + // Import + $this->migrator->import($user, $importSource, $this->output); + + // Verify calendar was recreated with migrated prefix + $calendars = $this->getCalendarsForUser($user); + $this->assertCount(1, $calendars, 'Expected one calendar after import'); + + $calendar = reset($calendars); + $this->assertEquals('migrated-' . $calendarUri, $calendar->getUri()); + $this->assertEquals('Round Trip Calendar', $calendar->getDisplayName()); + + // Verify events were imported + $objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey()); + $this->assertCount(1, $objects, 'Expected one event after import'); + } finally { + $this->deleteUser($user); + } + } + + public function testGetEstimatedExportSize(): void { + $user = $this->createTestUser(); + + try { + // Initially should be 0 or minimal + $initialSize = $this->migrator->getEstimatedExportSize($user); + $this->assertEquals(0, $initialSize); + + // Create a calendar with events + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $calendarUri = 'size-test-calendar'; + $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [ + '{DAV:}displayname' => 'Size Test Calendar', + ]); + + // Add an event + $icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics'); + $this->calDavBackend->createCalendarObject($calendarId, 'event.ics', $icsContent); + + // Size should now be > 0 + $sizeWithData = $this->migrator->getEstimatedExportSize($user); + $this->assertGreaterThan(0, $sizeWithData); + } finally { + $this->deleteUser($user); + } + } + + public function testImportExistingCalendarSkipped(): void { + $user = $this->createTestUser(); + + try { + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + + // Pre-create a calendar with the migrated prefix + $calendarUri = 'migrated-existing-calendar'; + $this->calDavBackend->createCalendar($principalUri, $calendarUri, [ + '{DAV:}displayname' => 'Existing Calendar', + ]); + + // Setup import for V2 + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); + + $importSource->method('getFolderListing') + ->with('dav/calendars/') + ->willReturn(['existing-calendar.meta', 'existing-calendar.data']); + + $importSource->method('getFileContents') + ->willReturn(json_encode([ + 'format' => 'ical', + 'uri' => 'existing-calendar', + 'label' => 'Existing Calendar', + 'color' => '#0082c9', + 'timezone' => null, + ])); + + $icsContent = file_get_contents(self::ASSETS_DIR . 'event-timed.ics'); + $importSource->method('getFileAsStream') + ->willReturnCallback(function () use ($icsContent) { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $icsContent); + rewind($stream); + return $stream; + }); + + // Import should use existing calendar + $this->migrator->import($user, $importSource, $this->output); + + // Should still have just one calendar + $calendars = $this->getCalendarsForUser($user); + $this->assertCount(1, $calendars); + } finally { + $this->deleteUser($user); + } } }