diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 73e9c3754902a..eee5fb6fb0bd7 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -11,472 +11,521 @@ 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\App\IAppManager; +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 Sabre\DAV\Xml\Property\Href; use Symfony\Component\Console\Output\OutputInterface; use Throwable; -use function substr; class CalendarMigrator implements IMigrator, ISizeEstimationMigrator { use TMigratorBasicVersionHandling; - private SabreDavServer $sabreDavServer; - + private const PATH_ROOT = Application::APP_ID . '/calendars/'; + private const PATH_VERSION = self::PATH_ROOT . 'version.json'; + private const PATH_CALENDARS = self::PATH_ROOT . 'calendars.json'; + private const PATH_SUBSCRIPTIONS = self::PATH_ROOT . 'subscriptions.json'; 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/'; + private const DAV_PROPERTY_URI = 'uri'; + private const DAV_PROPERTY_DISPLAYNAME = '{DAV:}displayname'; + private const DAV_PROPERTY_CALENDAR_COLOR = '{http://apple.com/ns/ical/}calendar-color'; + private const DAV_PROPERTY_CALENDAR_TIMEZONE = '{urn:ietf:params:xml:ns:caldav}calendar-timezone'; + private const DAV_PROPERTY_SUBSCRIBED_SOURCE = 'source'; + private const DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos'; + private const DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms'; + private const DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments'; public function __construct( - private CalDavBackend $calDavBackend, - private ICalendarManager $calendarManager, - private ICSExportPlugin $icsExportPlugin, - private Defaults $defaults, - private IL10N $l10n, + private readonly IAppManager $appManager, + 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()); + $this->version = 2; } - private function getPrincipalUri(IUser $user): string { - return CalendarMigrator::USERS_URI_ROOT . $user->getUID(); + /** + * {@inheritDoc} + */ + public function getId(): string { + return 'calendar'; } /** - * @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(); - } + public function getDisplayName(): string { + return $this->l10n->t('Calendar'); + } + + /** + * {@inheritDoc} + */ + public function getDescription(): string { + return $this->l10n->t('Calendars including events, details and attendees'); + } - /** - * @see \Sabre\CalDAV\ICSExportPlugin::generateResponse() implementation reference - */ + /** + * {@inheritDoc} + */ + public function getEstimatedExportSize(IUser $user): int|float { + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri); - $calDataProp = '{' . CalDAVPlugin::NS_CALDAV . '}calendar-data'; - $calendarNode = $this->sabreDavServer->tree->getNodeForPath($path); - $nodes = $this->sabreDavServer->getPropertiesIteratorForPath($path, [$calDataProp], 1); + $calendarCount = 0; + $totalSize = 0; - $blobs = []; - foreach ($nodes as $node) { - if (isset($node[200][$calDataProp])) { - $blobs[$node['href']] = $node[200][$calDataProp]; + 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); } } - $mergedCalendar = $this->icsExportPlugin->mergeObjects( - $properties, - $blobs, - ); + // 150B for meta file per calendar + total calendar data size + $size = ($calendarCount * 150 + $totalSize) / 1024; - $problems = $mergedCalendar->validate(); - if (!empty($problems)) { - $output->writeln('Skipping calendar "' . $properties['{DAV:}displayname'] . '" containing invalid calendar data'); - throw new InvalidCalendarException(); - } + return ceil($size); + } - return [ - 'name' => $calendarNode->getName(), - 'vCalendar' => $mergedCalendar, - ]; + /** + * {@inheritDoc} + */ + #[\Override] + public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting calendaring data…'); + $this->exportVersion($exportDestination, $output); + $this->exportCalendars($user, $exportDestination, $output); + $this->exportSubscriptions($user, $exportDestination, $output); } /** - * @return array - * * @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 exportVersion(IExportDestination $exportDestination, OutputInterface $output): void { + try { + $versionData = [ + 'appVersion' => $this->appManager->getAppVersion(Application::APP_ID), + ]; + $exportDestination->addFileContents(self::PATH_VERSION, json_encode($versionData, JSON_THROW_ON_ERROR)); + } catch (Throwable $e) { + throw new CalendarMigratorException('Could not export version information', 0, $e); + } } /** - * @throws InvalidCalendarException + * @throws CalendarMigratorException */ - 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(); - } + public function exportCalendars(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting calendars to ' . self::PATH_CALENDARS . '…'); - $existingCalendarUris = array_map( - fn (ICalendar $calendar) => $calendar->getUri(), - $this->calendarManager->getCalendarsForPrincipal($principalUri), - ); + try { + $calendarExports = $this->calendarManager->getCalendarsForPrincipal(self::USERS_URI_ROOT . $user->getUID()); - $calendarUri = $initialCalendarUri; - $acc = 1; - while (in_array($calendarUri, $existingCalendarUris, true)) { - $calendarUri = $initialCalendarUri . "-$acc"; - ++$acc; - } + $exportData = []; + /** @var CalendarImpl $calendar */ + foreach ($calendarExports as $calendar) { + $output->writeln('Exporting calendar "' . $calendar->getUri() . '"'); - return $calendarUri; - } + if (!$calendar instanceof CalendarImpl) { + $output->writeln('Skipping unsupported calendar type for "' . $calendar->getUri() . '"'); + continue; + } - /** - * {@inheritDoc} - */ - public function getEstimatedExportSize(IUser $user): int|float { - $calendarExports = $this->getCalendarExports($user, new NullOutput()); - $calendarCount = count($calendarExports); + if ($calendar->isShared()) { + $output->writeln('Skipping shared calendar "' . $calendar->getUri() . '"'); + continue; + } - // 150B for top-level properties - $size = ($calendarCount * 150) / 1024; + // construct archive path for calendar data + $filename = preg_replace('/[^a-z0-9-_]/iu', '', $calendar->getUri()); + $exportDataPath = self::PATH_ROOT . $filename . '.data'; + + // add calendar metadata to the collection + $exportData[] = [ + 'format' => 'ical', + 'uri' => $calendar->getUri(), + 'label' => $calendar->getDisplayName(), + 'color' => $calendar->getDisplayColor(), + 'timezone' => $calendar->getSchedulingTimezone(), + ]; + + // 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); + } - $componentCount = array_sum(array_map( - function (array $data): int { - /** @var VCalendar $vCalendar */ - $vCalendar = $data['vCalendar']; - return count($vCalendar->getComponents()); - }, - $calendarExports, - )); + // add the temporary file to the export archive + rewind($tempFile); + $exportDestination->addFileAsStream($exportDataPath, $tempFile); + fclose($tempFile); + } - // 450B for each component (events, todos, alarms, etc.) - $size += ($componentCount * 450) / 1024; + // write all calendar metadata + $exportDestination->addFileContents(self::PATH_CALENDARS, json_encode($exportData, JSON_THROW_ON_ERROR)); - return ceil($size); + $output->writeln('Exported ' . count($exportData) . ' calendar(s)…'); + } catch (Throwable $e) { + throw new CalendarMigratorException('Could not export calendars', 0, $e); + } } /** - * {@inheritDoc} + * @throws CalendarMigratorException */ - 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…'); - } + private function exportSubscriptions(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting calendar subscriptions to ' . self::PATH_SUBSCRIPTIONS . '…'); 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()); + $subscriptions = $this->calDavBackend->getSubscriptionsForUser(self::USERS_URI_ROOT . $user->getUID()); + + $exportData = []; + foreach ($subscriptions as $subscription) { + $exportData[] = [ + 'uri' => $subscription[self::DAV_PROPERTY_URI], + 'displayname' => $subscription[self::DAV_PROPERTY_DISPLAYNAME] ?? null, + 'color' => $subscription[self::DAV_PROPERTY_CALENDAR_COLOR] ?? null, + 'source' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_SOURCE] ?? null, + 'striptodos' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS] ?? null, + 'stripalarms' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS] ?? null, + 'stripattachments' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS] ?? null, + ]; } + + $exportDestination->addFileContents(self::PATH_SUBSCRIPTIONS, json_encode($exportData, JSON_THROW_ON_ERROR)); + + $output->writeln('Exported ' . count($exportData) . ' calendar subscription(s)…'); } catch (Throwable $e) { - throw new CalendarMigratorException('Could not export calendars', 0, $e); + throw new CalendarMigratorException('Could not export calendar subscriptions', 0, $e); } } /** - * @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 { + $output->writeln('Importing calendaring data…'); + if ($importSource->getMigratorVersion($this->getId()) === null) { + $output->writeln('No version for ' . static::class . ', skipping import…'); + return; } - return $calendarTimezoneMap; + $this->importCalendars($user, $importSource, $output); + $this->importSubscriptions($user, $importSource, $output); } /** - * @return VTimeZone[] + * @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 importCalendars(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln('Importing calendars from ' . self::PATH_ROOT . '…'); + + $migratorVersion = $importSource->getMigratorVersion($this->getId()); + match ($migratorVersion) { + 1 => $this->importCalendarsV1($user, $importSource, $output), + 2 => $this->importCalendarsV2($user, $importSource, $output), + default => throw new CalendarMigratorException('Unsupported migrator version ' . $migratorVersion . ' for ' . static::class), + }; + } + + /** + * @throws CalendarMigratorException + */ + public function importCalendarsV2(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln('Importing calendars from ' . self::PATH_CALENDARS . '…'); + + if ($importSource->pathExists(self::PATH_CALENDARS) === false) { + $output->writeln('No calendars to import…'); + return; } - $calendarTimezoneMap = $this->getCalendarTimezones($vCalendar); + $importData = $importSource->getFileContents(self::PATH_CALENDARS); + if (empty($importData)) { + $output->writeln('No calendars to import…'); + return; + } - return array_values(array_filter(array_map( - fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId], - $componentTimezoneIds, - ))); - } + try { + /** @var array> $calendarsData */ + $calendarsData = json_decode($importData, true, 512, JSON_THROW_ON_ERROR); - 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']); + if (empty($calendarsData)) { + $output->writeln('No calendars to import…'); + return; } - } - return $component; - } + $principalUri = self::USERS_URI_ROOT . $user->getUID(); - /** - * @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, - ]; - } + $importCount = 0; + foreach ($calendarsData as $calendarMeta) { + $migratedCalendarUri = self::MIGRATED_URI_PREFIX . $calendarMeta['uri']; + $filename = preg_replace('/[^a-z0-9-_]/iu', '', $calendarMeta['uri']); + $importDataPath = self::PATH_ROOT . $filename . '.data'; - private function initCalendarObject(): VCalendar { - $vCalendarObject = new VCalendar(); - $vCalendarObject->PRODID = '-//IDN nextcloud.com//Migrated calendar//EN'; - return $vCalendarObject; - } + try { + // 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, [ + self::DAV_PROPERTY_DISPLAYNAME => $calendarMeta['label'] ?? $this->l10n->t('Migrated calendar (%1$s)', [$calendarMeta['uri']]), + self::DAV_PROPERTY_CALENDAR_COLOR => $calendarMeta['color'] ?? $this->defaults->getColorPrimary(), + self::DAV_PROPERTY_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 { + $output->writeln("Using existing calendar \"$migratedCalendarUri\""); + } + $calendar = $calendars[0]; + + // 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->importSummary($calendarMeta['label'] ?? $calendarMeta['uri'], $outcome, $output); + + $importCount++; + } catch (Throwable $e) { + $output->writeln('Failed to import calendar "' . ($calendarMeta['uri'] ?? 'unknown') . '", skipping…'); + continue; + } + } - /** - * @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, - ); + $output->writeln('Imported ' . $importCount . ' calendar(s)…'); } catch (Throwable $e) { - $output->writeln("Error creating calendar object, rolling back creation of \"$filename\" calendar…"); - $this->calDavBackend->deleteCalendar($calendarId, true); - throw new InvalidCalendarException(); + throw new CalendarMigratorException('Could not import calendars', 0, $e); } } /** - * @throws InvalidCalendarException + * @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 $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; + public function importCalendarsV1(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $files = $importSource->getFolderListing(self::PATH_ROOT); + if (empty($files)) { + $output->writeln('No calendars to import…'); + } + + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + + foreach ($files as $filename) { + // Only process .ics files + if (!str_ends_with($filename, '.ics')) { + continue; + } + + // construct archive path + $importDataPath = self::PATH_ROOT . $filename; + + try { + $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, [ + self::DAV_PROPERTY_DISPLAYNAME => $calendarName ?? $this->l10n->t('Migrated calendar (%1$s)', [$calendarUri]), + self::DAV_PROPERTY_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 { - $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); + // 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->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($calendarName ?? $calendarUri, $outcome, $output); + } catch (Throwable $e) { + $output->writeln("Failed to import calendar \"$filename\", skipping…"); + continue; } - $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output); } } /** - * {@inheritDoc} - * * @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…'); + public function importSubscriptions(IUser $user, IImportSource $importSource, OutputInterface $output): void { + $output->writeln('Importing calendar subscriptions from ' . self::PATH_SUBSCRIPTIONS . '…'); + + if ($importSource->pathExists(self::PATH_SUBSCRIPTIONS) === false) { + $output->writeln('No calendar subscriptions to 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…'); + $importData = $importSource->getFileContents(self::PATH_SUBSCRIPTIONS); + if (empty($importData)) { + $output->writeln('No calendar subscriptions to import…'); + return; } - 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; - } + try { + $subscriptions = json_decode($importData, true, 512, JSON_THROW_ON_ERROR); - $problems = $vCalendar->validate(); - if (!empty($problems)) { - $output->writeln("Invalid calendar data contained in \"$importPath\", skipping…"); - continue; + if (empty($subscriptions)) { + $output->writeln('No calendar subscriptions to import…'); + return; } - $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; + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $importCount = 0; + foreach ($subscriptions as $subscription) { + $output->writeln('Importing calendar subscription "' . ($subscription['displayname'] ?? $subscription['source'] ?? 'unknown') . '"'); - try { - $this->importCalendar( - $user, - $filename, - $initialCalendarUri, - $vCalendar, - $output, + if (empty($subscription['source'])) { + $output->writeln('Skipping subscription without source URL'); + continue; + } + + $this->calDavBackend->createSubscription( + $principalUri, + $subscription['uri'] ? self::MIGRATED_URI_PREFIX . $subscription['uri'] : self::MIGRATED_URI_PREFIX . bin2hex(random_bytes(16)), + [ + '{http://calendarserver.org/ns/}source' => new Href($subscription['source']), + self::DAV_PROPERTY_DISPLAYNAME => $subscription['displayname'] ?? null, + self::DAV_PROPERTY_CALENDAR_COLOR => $subscription['color'] ?? null, + self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS => $subscription['striptodos'] ?? null, + self::DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS => $subscription['stripalarms'] ?? null, + self::DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS => $subscription['stripattachments'] ?? null, + ] ); - } catch (InvalidCalendarException $e) { - // Allow this exception to skip a failed import - } finally { - $vCalendar->destroy(); + $importCount++; } - } - } - /** - * {@inheritDoc} - */ - public function getId(): string { - return 'calendar'; + $output->writeln('Imported ' . $importCount . ' subscription(s)…'); + } catch (Throwable $e) { + throw new CalendarMigratorException('Could not import calendar subscriptions', 0, $e); + } } - /** - * {@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 e32f9979822ed..0c1df7720846c 100644 --- a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php +++ b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php @@ -10,107 +10,659 @@ 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(name: '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 createTestUser(): IUser { + $userId = UUIDUtil::getUUID(); + return $this->userManager->createUser($userId, 'topsecretpassword'); + } + + private function deleteUser(IUser $user): void { + $user->delete(); + } + + 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 function testImportV1(): void { + $user = $this->createTestUser(); + + try { + // Get all asset files + $files = scandir(self::ASSETS_DIR); + $this->assertNotFalse($files, 'Failed to scan assets directory'); + $files = array_values(array_diff($files, ['.', '..'])); + $this->assertNotEmpty($files, 'No asset files found'); + + // Load all ICS content + $icsContents = []; + foreach ($files as $filename) { + $icsContents[$filename] = file_get_contents(self::ASSETS_DIR . $filename); + } + + // Setup import source mock + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(1); + $importSource->method('getFolderListing') + ->with('dav/calendars/') + ->willReturn($files); + $importSource->method('getFileAsStream') + ->willReturnCallback(function (string $path) use ($icsContents) { + foreach ($icsContents as $filename => $content) { + if ($path === 'dav/calendars/' . $filename) { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $content); + rewind($stream); + return $stream; + } + } + throw new \Exception("Unexpected path: $path"); + }); + + // Import all calendars + $this->migrator->import($user, $importSource, $this->output); + + // Verify all calendars were created + $calendars = $this->getCalendarsForUser($user); + $this->assertCount(count($files), $calendars, 'Expected all calendars to be created'); + + // Verify each calendar has the migrated prefix and has objects + foreach ($files as $filename) { + $expectedUri = 'migrated-' . substr($filename, 0, -4); + $found = false; + foreach ($calendars as $calendar) { + if ($calendar->getUri() === $expectedUri) { + $found = true; + // Verify calendar has objects + $objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey()); + $this->assertNotEmpty($objects, "Expected calendar $expectedUri to have objects"); + break; + } + } + $this->assertTrue($found, "Calendar with URI $expectedUri was not found"); + } + } finally { + $this->deleteUser($user); + } + } + + public function testImportV2(): void { + $user = $this->createTestUser(); + + try { + // Get all asset files + $files = scandir(self::ASSETS_DIR); + $this->assertNotFalse($files, 'Failed to scan assets directory'); + $files = array_values(array_diff($files, ['.', '..'])); + $this->assertNotEmpty($files, 'No asset files found'); + + // Load all ICS content and build calendars metadata + $calendarsMetadata = []; + $icsContents = []; + foreach ($files as $filename) { + $icsContent = file_get_contents(self::ASSETS_DIR . $filename); + $calendarUri = substr($filename, 0, -4); + $icsContents[$calendarUri] = $icsContent; + + // 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; + } + } + + $calendarsMetadata[] = [ + 'format' => 'ical', + 'uri' => $calendarUri, + 'label' => $calendarName ?? $calendarUri, + 'color' => $calendarColor ?? '#0082c9', + 'timezone' => null, + ]; + } + + // Setup import source mock for V2 format (.meta + .data files) + $importSource = $this->createMock(IImportSource::class); + $calendarsJson = json_encode($calendarsMetadata); + + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); + + $importSource->method('pathExists') + ->willReturnCallback(function (string $path) { + if ($path === 'dav/calendars/calendars.json') { + return true; + } + if ($path === 'dav/calendars/subscriptions.json') { + return false; + } + return false; + }); + + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($calendarsJson) { + if ($path === 'dav/calendars/calendars.json') { + return $calendarsJson; + } + throw new \Exception("Unexpected path: $path"); + }); + + $importSource->method('getFileAsStream') + ->willReturnCallback(function (string $path) use ($icsContents) { + foreach ($icsContents as $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 all calendars + $this->migrator->import($user, $importSource, $this->output); + + // Verify all calendars were created + $calendars = $this->getCalendarsForUser($user); + $this->assertCount(count($files), $calendars, 'Expected all calendars to be created'); + + // Verify each calendar has the correct properties and objects + foreach ($calendarsMetadata as $metadata) { + $expectedUri = 'migrated-' . $metadata['uri']; + $found = false; + foreach ($calendars as $calendar) { + if ($calendar->getUri() === $expectedUri) { + $found = true; + // Verify calendar display name + $this->assertEquals($metadata['label'], $calendar->getDisplayName()); + // Verify calendar has objects + $objects = $this->calDavBackend->getCalendarObjects((int)$calendar->getKey()); + $this->assertNotEmpty($objects, "Expected calendar $expectedUri to have objects"); + break; + } + } + $this->assertTrue($found, "Calendar with URI $expectedUri was not found"); + } + } 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); + + $exportedCalendarsJson = null; + $exportedData = null; + + $exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $content) use (&$exportedCalendarsJson) { + if ($path === 'dav/calendars/calendars.json') { + $exportedCalendarsJson = 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 calendars.json was exported + $this->assertNotNull($exportedCalendarsJson, 'Expected calendars.json to be exported'); + $this->assertIsArray($exportedCalendarsJson); + $this->assertCount(1, $exportedCalendarsJson); + $exportedMeta = $exportedCalendarsJson[0]; + $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('pathExists') + ->willReturnCallback(function (string $path) use ($exportedFiles) { + return isset($exportedFiles[$path]); + }); + $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); + } } - 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, - )), - ); + 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('pathExists') + ->willReturnCallback(function (string $path) { + if ($path === 'dav/calendars/calendars.json') { + return true; + } + if ($path === 'dav/calendars/subscriptions.json') { + return false; + } + return false; + }); + + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) { + if ($path === 'dav/calendars/calendars.json') { + return json_encode([[ + 'format' => 'ical', + 'uri' => 'existing-calendar', + 'label' => 'Existing Calendar', + 'color' => '#0082c9', + 'timezone' => null, + ]]); + } + throw new \Exception("Unexpected path: $path"); + }); + // 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); + } } - private function getComponents(VCalendar $vCalendar): array { - return array_map( - // Elements of the serialized blob are sorted - fn (VObjectComponent $component) => $component->serialize(), - $vCalendar->getComponents(), - ); + public function testExportSubscriptions(): void { + $user = $this->createTestUser(); + + try { + // Create a subscription to export + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $this->calDavBackend->createSubscription( + $principalUri, + 'test-subscription', + [ + '{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://example.com/calendar.ics'), + '{DAV:}displayname' => 'Test Subscription', + '{http://apple.com/ns/ical/}calendar-color' => '#ff0000', + '{http://calendarserver.org/ns/}subscribed-strip-todos' => '1', + ] + ); + + // Setup export destination mock + $exportDestination = $this->createMock(IExportDestination::class); + + $exportedSubscriptionsJson = null; + + $exportDestination->method('addFileContents') + ->willReturnCallback(function (string $path, string $content) use (&$exportedSubscriptionsJson) { + if ($path === 'dav/calendars/subscriptions.json') { + $exportedSubscriptionsJson = json_decode($content, true); + } + }); + + $exportDestination->method('addFileAsStream'); + + // Export + $this->migrator->export($user, $exportDestination, $this->output); + + // Verify exported subscription data + $this->assertNotNull($exportedSubscriptionsJson, 'Subscriptions JSON should be exported'); + $this->assertCount(1, $exportedSubscriptionsJson, 'Expected one subscription in export'); + + $exportedSubscription = $exportedSubscriptionsJson[0]; + $this->assertEquals('test-subscription', $exportedSubscription['uri']); + $this->assertEquals('Test Subscription', $exportedSubscription['displayname']); + $this->assertEquals('#ff0000', $exportedSubscription['color']); + $this->assertEquals('https://example.com/calendar.ics', $exportedSubscription['source']); + $this->assertEquals('1', $exportedSubscription['striptodos']); + } finally { + $this->deleteUser($user); + } } - 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(), - ); + public function testImportSubscriptions(): void { + $user = $this->createTestUser(); + + try { + // Setup import source mock + $importSource = $this->createMock(IImportSource::class); + + $subscriptionsJson = json_encode([[ + 'uri' => 'imported-subscription', + 'displayname' => 'Imported Subscription', + 'color' => '#00ff00', + 'source' => 'https://example.com/imported.ics', + 'striptodos' => null, + 'stripalarms' => '1', + 'stripattachments' => null, + ]]); + + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); + + $importSource->method('pathExists') + ->willReturnCallback(function (string $path) { + if ($path === 'dav/calendars/subscriptions.json') { + return true; + } + if ($path === 'dav/calendars/calendars.json') { + return false; + } + return false; + }); + + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($subscriptionsJson) { + if ($path === 'dav/calendars/subscriptions.json') { + return $subscriptionsJson; + } + if ($path === 'dav/calendars/calendars.json') { + // Return empty calendars array + return json_encode([]); + } + throw new \Exception("Unexpected path: $path"); + }); + + // Import + $this->migrator->import($user, $importSource, $this->output); + + // Verify subscription was created + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri); + $this->assertCount(1, $subscriptions); + + $subscription = $subscriptions[0]; + $this->assertEquals('migrated-imported-subscription', $subscription['uri']); + $this->assertEquals('Imported Subscription', $subscription['{DAV:}displayname']); + $this->assertEquals('#00ff00', $subscription['{http://apple.com/ns/ical/}calendar-color']); + $this->assertEquals('1', $subscription['{http://calendarserver.org/ns/}subscribed-strip-alarms']); + } finally { + $this->deleteUser($user); + } } - #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataAssets')] - public function testImportExportAsset(string $userId, string $filename, string $initialCalendarUri, VCalendar $importCalendar): void { - $user = $this->userManager->createUser($userId, 'topsecretpassword'); + public function testExportImportSubscriptionsRoundTrip(): void { + $user = $this->createTestUser(); + + try { + // Create subscriptions to export + $principalUri = self::USERS_URI_ROOT . $user->getUID(); + $this->calDavBackend->createSubscription( + $principalUri, + 'roundtrip-subscription', + [ + '{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://example.com/roundtrip.ics'), + '{DAV:}displayname' => 'Round Trip Subscription', + '{http://apple.com/ns/ical/}calendar-color' => '#0000ff', + ] + ); + + // 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'); + + // Export + $this->migrator->export($user, $exportDestination, $this->output); + + // Delete the original subscription + $subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri); + foreach ($subscriptions as $subscription) { + $this->calDavBackend->deleteSubscription($subscription['id']); + } + + // Verify subscription is gone + $subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri); + $this->assertEmpty($subscriptions, 'Subscription should be deleted'); - $problems = $importCalendar->validate(); - $this->assertEmpty($problems); + // Setup import source from exported data + $importSource = $this->createMock(IImportSource::class); + $importSource->method('getMigratorVersion') + ->with('calendar') + ->willReturn(2); - $this->invokePrivate($this->migrator, 'importCalendar', [$user, $filename, $initialCalendarUri, $importCalendar, $this->output]); + $importSource->method('pathExists') + ->willReturnCallback(function (string $path) use ($exportedFiles) { + return isset($exportedFiles[$path]); + }); - $calendarExports = $this->invokePrivate($this->migrator, 'getCalendarExports', [$user, $this->output]); - $this->assertCount(1, $calendarExports); + $importSource->method('getFileContents') + ->willReturnCallback(function (string $path) use ($exportedFiles) { + if (isset($exportedFiles[$path])) { + return $exportedFiles[$path]; + } + // Return empty for missing files + if ($path === 'dav/calendars/calendars.json') { + return json_encode([]); + } + throw new \Exception("File not found: $path"); + }); - /** @var VCalendar $exportCalendar */ - ['vCalendar' => $exportCalendar] = reset($calendarExports); + // Import + $this->migrator->import($user, $importSource, $this->output); - $this->assertEqualsCanonicalizing( - $this->getProperties($importCalendar), - $this->getProperties($exportCalendar), - ); + // Verify subscription was recreated with migrated prefix + $subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri); + $this->assertCount(1, $subscriptions, 'Expected one subscription after import'); - $this->assertEqualsCanonicalizing( - // Components are expected to be sanitized on import - $this->getSanitizedComponents($importCalendar), - $this->getComponents($exportCalendar), - ); + $subscription = $subscriptions[0]; + $this->assertEquals('migrated-roundtrip-subscription', $subscription['uri']); + $this->assertEquals('Round Trip Subscription', $subscription['{DAV:}displayname']); + $this->assertEquals('#0000ff', $subscription['{http://apple.com/ns/ical/}calendar-color']); + } finally { + $this->deleteUser($user); + } } }