version = 2; } /** * {@inheritDoc} */ public function getId(): string { return 'calendar'; } /** * {@inheritDoc} */ public function getDisplayName(): string { return $this->l10n->t('Calendar'); } /** * {@inheritDoc} */ public function getDescription(): string { return $this->l10n->t('Calendars including events, details and attendees'); } /** * {@inheritDoc} */ public function getEstimatedExportSize(IUser $user): int|float { $principalUri = self::USERS_URI_ROOT . $user->getUID(); $calendars = $this->calendarManager->getCalendarsForPrincipal($principalUri); $calendarCount = 0; $totalSize = 0; 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); } } // 150B for meta file per calendar + total calendar data size $size = ($calendarCount * 150 + $totalSize) / 1024; return ceil($size); } /** * {@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); } /** * @throws CalendarMigratorException */ 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 CalendarMigratorException */ public function exportCalendars(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { $output->writeln('Exporting calendars to ' . self::PATH_CALENDARS . '…'); try { $calendarExports = $this->calendarManager->getCalendarsForPrincipal(self::USERS_URI_ROOT . $user->getUID()); $exportData = []; /** @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 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); } // add the temporary file to the export archive rewind($tempFile); $exportDestination->addFileAsStream($exportDataPath, $tempFile); fclose($tempFile); } // write all calendar metadata $exportDestination->addFileContents(self::PATH_CALENDARS, json_encode($exportData, JSON_THROW_ON_ERROR)); $output->writeln('Exported ' . count($exportData) . ' calendar(s)…'); } catch (Throwable $e) { throw new CalendarMigratorException('Could not export calendars', 0, $e); } } /** * @throws CalendarMigratorException */ private function exportSubscriptions(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { $output->writeln('Exporting calendar subscriptions to ' . self::PATH_SUBSCRIPTIONS . '…'); try { $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 calendar subscriptions', 0, $e); } } /** * {@inheritDoc} * * @throws CalendarMigratorException */ 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; } $this->importCalendars($user, $importSource, $output); $this->importSubscriptions($user, $importSource, $output); } /** * @throws CalendarMigratorException */ 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; } $importData = $importSource->getFileContents(self::PATH_CALENDARS); if (empty($importData)) { $output->writeln('No calendars to import…'); return; } try { /** @var array> $calendarsData */ $calendarsData = json_decode($importData, true, 512, JSON_THROW_ON_ERROR); if (empty($calendarsData)) { $output->writeln('No calendars to import…'); return; } $principalUri = self::USERS_URI_ROOT . $user->getUID(); $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'; 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; } } $output->writeln('Imported ' . $importCount . ' calendar(s)…'); } catch (Throwable $e) { throw new CalendarMigratorException('Could not import calendars', 0, $e); } } /** * @throws CalendarMigratorException */ 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 { $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; } } } /** * @throws CalendarMigratorException */ 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; } $importData = $importSource->getFileContents(self::PATH_SUBSCRIPTIONS); if (empty($importData)) { $output->writeln('No calendar subscriptions to import…'); return; } try { $subscriptions = json_decode($importData, true, 512, JSON_THROW_ON_ERROR); if (empty($subscriptions)) { $output->writeln('No calendar subscriptions to import…'); return; } $principalUri = self::USERS_URI_ROOT . $user->getUID(); $importCount = 0; foreach ($subscriptions as $subscription) { $output->writeln('Importing calendar subscription "' . ($subscription['displayname'] ?? $subscription['source'] ?? 'unknown') . '"'); 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, ] ); $importCount++; } $output->writeln('Imported ' . $importCount . ' subscription(s)…'); } catch (Throwable $e) { throw new CalendarMigratorException('Could not import calendar subscriptions', 0, $e); } } 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, }; } $output->writeln(" \"$label\": $created created, $updated updated, $skipped skipped, $errors errors"); } }