]> source.dussan.org Git - nextcloud-server.git/commitdiff
Calendar export and import
authorChristopher Ng <chrng8@gmail.com>
Tue, 8 Feb 2022 06:54:07 +0000 (06:54 +0000)
committerChristopher Ng <chrng8@gmail.com>
Wed, 2 Mar 2022 01:59:15 +0000 (01:59 +0000)
Signed-off-by: Christopher Ng <chrng8@gmail.com>
apps/dav/appinfo/info.xml
apps/dav/composer/composer/autoload_classmap.php
apps/dav/composer/composer/autoload_static.php
apps/dav/lib/CalDAV/CalendarImpl.php
apps/dav/lib/Command/ExportCalendars.php [new file with mode: 0644]
apps/dav/lib/Command/ImportCalendar.php [new file with mode: 0644]
apps/dav/lib/UserMigration/CalendarMigrator.php [new file with mode: 0644]
apps/dav/lib/UserMigration/CalendarMigratorException.php [new file with mode: 0644]
apps/dav/lib/UserMigration/InvalidCalendarException.php [new file with mode: 0644]
lib/public/Calendar/ICalendar.php

index 8462ed1816d4255c83abf7b0df21a3ca26d87515..88c4ee03ac37389fcd9dacd43964d03f19e8d6d2 100644 (file)
@@ -56,6 +56,8 @@
                <command>OCA\DAV\Command\SyncBirthdayCalendar</command>
                <command>OCA\DAV\Command\SyncSystemAddressBook</command>
                <command>OCA\DAV\Command\RemoveInvalidShares</command>
+               <command>OCA\DAV\Command\ExportCalendars</command>
+               <command>OCA\DAV\Command\ImportCalendar</command>
        </commands>
 
        <settings>
index 9cfefaed1c85d5468e29e31bb6b712eff9310253..bf0fe9cbf750214d7a32f1aa735072148a40d4f9 100644 (file)
@@ -124,6 +124,8 @@ return array(
     'OCA\\DAV\\Command\\CreateAddressBook' => $baseDir . '/../lib/Command/CreateAddressBook.php',
     'OCA\\DAV\\Command\\CreateCalendar' => $baseDir . '/../lib/Command/CreateCalendar.php',
     'OCA\\DAV\\Command\\DeleteCalendar' => $baseDir . '/../lib/Command/DeleteCalendar.php',
+    'OCA\\DAV\\Command\\ExportCalendars' => $baseDir . '/../lib/Command/ExportCalendars.php',
+    'OCA\\DAV\\Command\\ImportCalendar' => $baseDir . '/../lib/Command/ImportCalendar.php',
     'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php',
     'OCA\\DAV\\Command\\MoveCalendar' => $baseDir . '/../lib/Command/MoveCalendar.php',
     'OCA\\DAV\\Command\\RemoveInvalidShares' => $baseDir . '/../lib/Command/RemoveInvalidShares.php',
@@ -299,4 +301,7 @@ return array(
     'OCA\\DAV\\Upload\\UploadFile' => $baseDir . '/../lib/Upload/UploadFile.php',
     'OCA\\DAV\\Upload\\UploadFolder' => $baseDir . '/../lib/Upload/UploadFolder.php',
     'OCA\\DAV\\Upload\\UploadHome' => $baseDir . '/../lib/Upload/UploadHome.php',
+    'OCA\\DAV\\UserMigration\\CalendarMigrator' => $baseDir . '/../lib/UserMigration/CalendarMigrator.php',
+    'OCA\\DAV\\UserMigration\\CalendarMigratorException' => $baseDir . '/../lib/UserMigration/CalendarMigratorException.php',
+    'OCA\\DAV\\UserMigration\\InvalidCalendarException' => $baseDir . '/../lib/UserMigration/InvalidCalendarException.php',
 );
index 01c24fe38c89850bbe8dd0db5134cd0bd73deaa3..18bfd64296029e20eb2fa2284d9ec45d5030ee45 100644 (file)
@@ -139,6 +139,8 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\Command\\CreateAddressBook' => __DIR__ . '/..' . '/../lib/Command/CreateAddressBook.php',
         'OCA\\DAV\\Command\\CreateCalendar' => __DIR__ . '/..' . '/../lib/Command/CreateCalendar.php',
         'OCA\\DAV\\Command\\DeleteCalendar' => __DIR__ . '/..' . '/../lib/Command/DeleteCalendar.php',
+        'OCA\\DAV\\Command\\ExportCalendars' => __DIR__ . '/..' . '/../lib/Command/ExportCalendars.php',
+        'OCA\\DAV\\Command\\ImportCalendar' => __DIR__ . '/..' . '/../lib/Command/ImportCalendar.php',
         'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php',
         'OCA\\DAV\\Command\\MoveCalendar' => __DIR__ . '/..' . '/../lib/Command/MoveCalendar.php',
         'OCA\\DAV\\Command\\RemoveInvalidShares' => __DIR__ . '/..' . '/../lib/Command/RemoveInvalidShares.php',
@@ -314,6 +316,9 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\Upload\\UploadFile' => __DIR__ . '/..' . '/../lib/Upload/UploadFile.php',
         'OCA\\DAV\\Upload\\UploadFolder' => __DIR__ . '/..' . '/../lib/Upload/UploadFolder.php',
         'OCA\\DAV\\Upload\\UploadHome' => __DIR__ . '/..' . '/../lib/Upload/UploadHome.php',
+        'OCA\\DAV\\UserMigration\\CalendarMigrator' => __DIR__ . '/..' . '/../lib/UserMigration/CalendarMigrator.php',
+        'OCA\\DAV\\UserMigration\\CalendarMigratorException' => __DIR__ . '/..' . '/../lib/UserMigration/CalendarMigratorException.php',
+        'OCA\\DAV\\UserMigration\\InvalidCalendarException' => __DIR__ . '/..' . '/../lib/UserMigration/InvalidCalendarException.php',
     );
 
     public static function getInitializer(ClassLoader $loader)
index 1c36db68cca852a353ccbfe91a8b620106ae637b..87bed32428f7d473a7b3f05db102b7fc6ac5bfad 100644 (file)
@@ -69,6 +69,13 @@ class CalendarImpl implements ICreateFromString {
                return $this->calendarInfo['id'];
        }
 
+       /**
+        * {@inheritDoc}
+        */
+       public function getUri() {
+               return $this->calendarInfo['uri'];
+       }
+
        /**
         * In comparison to getKey() this function returns a human readable (maybe translated) name
         * @return null|string
diff --git a/apps/dav/lib/Command/ExportCalendars.php b/apps/dav/lib/Command/ExportCalendars.php
new file mode 100644 (file)
index 0000000..770d6ed
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2022 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\DAV\Command;
+
+use OC\Core\Command\Base;
+use OCA\DAV\UserMigration\CalendarMigrator;
+use OCA\DAV\UserMigration\CalendarMigratorException;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ExportCalendars extends Base {
+
+       /** @var IUserManager */
+       private $userManager;
+
+       /** @var CalendarMigrator */
+       private $calendarMigrator;
+
+       public function __construct(
+               IUserManager $userManager,
+               CalendarMigrator $calendarMigrator
+       ) {
+               parent::__construct();
+               $this->userManager = $userManager;
+               $this->calendarMigrator = $calendarMigrator;
+       }
+
+       protected function configure() {
+               $this
+                       ->setName('dav:export-calendars')
+                       ->setDescription('Export the calendars of a user')
+                       ->addArgument(
+                               'user',
+                               InputArgument::REQUIRED,
+                               'User to export',
+                       );
+       }
+
+       protected function execute(InputInterface $input, OutputInterface $output): int {
+               $user = $this->userManager->get($input->getArgument('user'));
+
+               if (!$user instanceof IUser) {
+                       $output->writeln('<error>User ' . $input->getArgument('user') . ' does not exist</error>');
+                       return 1;
+               }
+
+               try {
+                       $this->calendarMigrator->export($user, $output);
+               } catch (CalendarMigratorException $e) {
+                       $output->writeln('<error>' . $e->getMessage() . '</error>');
+                       return $e->getCode() !== 0 ? (int)$e->getCode() : 1;
+               }
+
+               return 0;
+       }
+}
diff --git a/apps/dav/lib/Command/ImportCalendar.php b/apps/dav/lib/Command/ImportCalendar.php
new file mode 100644 (file)
index 0000000..193ecc3
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2022 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\DAV\Command;
+
+use OC\Core\Command\Base;
+use OCA\DAV\UserMigration\CalendarMigrator;
+use OCA\DAV\UserMigration\CalendarMigratorException;
+use OCP\IUser;
+use OCP\IUserManager;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ImportCalendar extends Base {
+
+       /** @var IUserManager */
+       private $userManager;
+
+       /** @var CalendarMigrator */
+       private $calendarMigrator;
+
+       public function __construct(
+               IUserManager $userManager,
+               CalendarMigrator $calendarMigrator
+       ) {
+               parent::__construct();
+               $this->userManager = $userManager;
+               $this->calendarMigrator = $calendarMigrator;
+       }
+
+       protected function configure() {
+               $this
+                       ->setName('dav:import-calendar')
+                       ->setDescription('Import a calendar to a user\'s account')
+                       ->addArgument(
+                               'user',
+                               InputArgument::REQUIRED,
+                               'User to import the calendar for',
+                       )
+                       ->addArgument(
+                               'path',
+                               InputArgument::REQUIRED,
+                               'Path to the *.ics file',
+                       );
+       }
+
+       protected function execute(InputInterface $input, OutputInterface $output): int {
+               $user = $this->userManager->get($input->getArgument('user'));
+
+               [
+                       'basename' => $filename,
+                       'dirname' => $srcDir,
+               ] = pathinfo($input->getArgument('path'));
+
+
+               if (!$user instanceof IUser) {
+                       $output->writeln('<error>User ' . $input->getArgument('user') . ' does not exist</error>');
+                       return 1;
+               }
+
+               try {
+                       $this->calendarMigrator->import($user, $srcDir, $filename, $output);
+               } catch (CalendarMigratorException $e) {
+                       $output->writeln('<error>' . $e->getMessage() . '</error>');
+                       return $e->getCode() !== 0 ? (int)$e->getCode() : 1;
+               }
+
+               return 0;
+       }
+}
diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php
new file mode 100644 (file)
index 0000000..c1252d7
--- /dev/null
@@ -0,0 +1,457 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2022 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\DAV\UserMigration;
+
+use function Safe\fopen;
+use function Safe\substr;
+use OC\Files\Filesystem;
+use OC\Files\View;
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin;
+use OCA\DAV\CalDAV\Plugin as CalDAVPlugin;
+use OCA\DAV\Connector\Sabre\CachingTree;
+use OCA\DAV\Connector\Sabre\Server as SabreDavServer;
+use OCA\DAV\RootCollection;
+use OCP\Calendar\ICalendar;
+use OCP\Calendar\IManager as ICalendarManager;
+use OCP\Defaults;
+use OCP\IL10N;
+use OCP\IUser;
+use Sabre\DAV\Exception\BadRequest;
+use Sabre\DAV\Version as SabreDavVersion;
+use Sabre\VObject\Component as VObjectComponent;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VTimeZone;
+use Sabre\VObject\Property\ICalendar\DateTime;
+use Sabre\VObject\Reader as VObjectReader;
+use Sabre\VObject\UUIDUtil;
+use Safe\Exceptions\FilesystemException;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class CalendarMigrator {
+
+       private CalDavBackend $calDavBackend;
+
+       private ICalendarManager $calendarManager;
+
+       // ICSExportPlugin is injected as the mergeObjects() method is required and is not to be used as a SabreDAV server plugin
+       private ICSExportPlugin $icsExportPlugin;
+
+       private Defaults $defaults;
+
+       private IL10N $l10n;
+
+       private SabreDavServer $sabreDavServer;
+
+       public const USERS_URI_ROOT = 'principals/users/';
+
+       public const FILENAME_EXT = '.ics';
+
+       public const MIGRATED_URI_PREFIX = 'migrated-';
+
+       public function __construct(
+               CalDavBackend $calDavBackend,
+               ICalendarManager $calendarManager,
+               ICSExportPlugin $icsExportPlugin,
+               Defaults $defaults,
+               IL10N $l10n
+       ) {
+               $this->calDavBackend = $calDavBackend;
+               $this->calendarManager = $calendarManager;
+               $this->icsExportPlugin = $icsExportPlugin;
+               $this->defaults = $defaults;
+               $this->l10n = $l10n;
+
+               $root = new RootCollection();
+               $this->sabreDavServer = new SabreDavServer(new CachingTree($root));
+               $this->sabreDavServer->addPlugin(new CalDAVPlugin());
+       }
+
+       public function getPrincipalUri(IUser $user): string {
+               return CalendarMigrator::USERS_URI_ROOT . $user->getUID();
+       }
+
+       /**
+        * @return array{name: string, vCalendar: VCalendar}
+        *
+        * @throws CalendarMigratorException
+        * @throws InvalidCalendarException
+        */
+       public function getCalendarExportData(IUser $user, ICalendar $calendar): array {
+               $userId = $user->getUID();
+               $calendarId = $calendar->getKey();
+               $calendarInfo = $this->calDavBackend->getCalendarById($calendarId);
+
+               if (!empty($calendarInfo)) {
+                       $uri = $calendarInfo['uri'];
+                       $path = CalDAVPlugin::CALENDAR_ROOT . "/$userId/$uri";
+
+                       // NOTE implementation below based on \Sabre\CalDAV\ICSExportPlugin::httpGet()
+
+                       $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();
+                       }
+
+                       // NOTE implementation below based on \Sabre\CalDAV\ICSExportPlugin::generateResponse()
+
+                       $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,
+                       );
+
+                       return [
+                               'name' => $calendarNode->getName(),
+                               'vCalendar' => $mergedCalendar,
+                       ];
+               }
+
+               throw new CalendarMigratorException();
+       }
+
+       /**
+        * @return array<int, array{name: string, vCalendar: VCalendar}>
+        *
+        * @throws CalendarMigratorException
+        */
+       public function getCalendarExports(IUser $user): array {
+               $principalUri = $this->getPrincipalUri($user);
+
+               return array_values(array_filter(array_map(
+                       function (ICalendar $calendar) use ($user) {
+                               try {
+                                       return $this->getCalendarExportData($user, $calendar);
+                               } catch (CalendarMigratorException $e) {
+                                       throw new CalendarMigratorException();
+                               } catch (InvalidCalendarException $e) {
+                                       // Allow this exception as invalid (e.g. deleted) calendars are not to be exported
+                               }
+                       },
+                       $this->calendarManager->getCalendarsForPrincipal($principalUri),
+               )));
+       }
+
+       public 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;
+
+               $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;
+       }
+
+       /**
+        * @throws CalendarMigratorException
+        */
+       protected function writeExport(IUser $user, string $data, string $destDir, string $filename, OutputInterface $output): void {
+               $userId = $user->getUID();
+
+               \OC::$server->getUserFolder($userId);
+               Filesystem::initMountPoints($userId);
+
+               $view = new View();
+
+               if ($view->file_put_contents("$destDir/$filename", $data) === false) {
+                       throw new CalendarMigratorException('Could not export calendar');
+               }
+
+               $output->writeln("<info>✅ Exported calendar of <$userId> into $destDir/$filename</info>");
+       }
+
+       public function export(IUser $user, OutputInterface $output): void {
+               $userId = $user->getUID();
+
+               try {
+                       $calendarExports = $this->getCalendarExports($user);
+               } catch (CalendarMigratorException $e) {
+                       $output->writeln("<error>Error exporting <$userId> calendars</error>");
+               }
+
+               if (empty($calendarExports)) {
+                       $output->writeln("<info>User <$userId> has no calendars to export</info>");
+               }
+
+               /**
+                * @var string $name
+                * @var VCalendar $vCalendar
+                */
+               foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) {
+                       // Set filename to sanitized calendar name appended with the date
+                       $filename = preg_replace('/[^a-zA-Z0-9-_ ]/um', '', $name) . '_' . date('Y-m-d') . CalendarMigrator::FILENAME_EXT;
+
+                       $this->writeExport(
+                               $user,
+                               $vCalendar->serialize(),
+                               // TESTING directory does not automatically get created so just write to user directory, this will be put in a zip with all other user_migration data
+                               // "/$userId/export/$appId",
+                               "/$userId",
+                               $filename,
+                               $output,
+                       );
+               }
+       }
+
+       /**
+        * Return an associative array mapping Time Zone ID to VTimeZone component
+        *
+        * @return array<string, VTimeZone>
+        */
+       public function getCalendarTimezones(VCalendar $vCalendar): array {
+               /** @var VTimeZone[] $calendarTimezones */
+               $calendarTimezones = array_values(array_filter(
+                       $vCalendar->getComponents(),
+                       fn ($component) => $component->name === 'VTIMEZONE',
+               ));
+
+               /** @var array<string, VTimeZone> $calendarTimezoneMap */
+               $calendarTimezoneMap = [];
+               foreach ($calendarTimezones as $vTimeZone) {
+                       $calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone;
+               }
+
+               return $calendarTimezoneMap;
+       }
+
+       /**
+        * @return VTimeZone[]
+        */
+       public function getTimezonesForComponent(VCalendar $vCalendar, VObjectComponent $component): array {
+               $componentTimezoneIds = [];
+
+               foreach ($component->children() as $child) {
+                       if ($child instanceof DateTime && isset($child->parameters['TZID'])) {
+                               $timezoneId = $child->parameters['TZID']->getValue();
+                               if (!in_array($timezoneId, $componentTimezoneIds, true)) {
+                                       $componentTimezoneIds[] = $timezoneId;
+                               }
+                       }
+               }
+
+               $calendarTimezoneMap = $this->getCalendarTimezones($vCalendar);
+
+               return array_values(array_filter(array_map(
+                       fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId],
+                       $componentTimezoneIds,
+               )));
+       }
+
+       public function sanitizeComponent(VObjectComponent $component): VObjectComponent {
+               // Operate on the component clone to prevent mutation of the original
+               $componentClone = clone $component;
+
+               // Remove RSVP parameters to prevent automatically sending invitation emails to attendees on import
+               foreach ($componentClone->children() as $child) {
+                       if (
+                               $child->name === 'ATTENDEE'
+                               && isset($child->parameters['RSVP'])
+                       ) {
+                               unset($child->parameters['RSVP']);
+                       }
+               }
+
+               return $componentClone;
+       }
+
+       /**
+        * @return VObjectComponent[]
+        */
+       public function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array {
+               $component = $this->sanitizeComponent($component);
+               /** @var array<int, VTimeZone> $timezoneComponents */
+               $timezoneComponents = $this->getTimezonesForComponent($vCalendar, $component);
+               return [
+                       ...$timezoneComponents,
+                       $component,
+               ];
+       }
+
+       public function initCalendarObject(): VCalendar {
+               $vCalendarObject = new VCalendar();
+               $vCalendarObject->PRODID = $this->sabreDavServer::$exposeVersion
+                       ? '-//SabreDAV//SabreDAV ' . SabreDavVersion::VERSION . '//EN'
+                       : '-//SabreDAV//SabreDAV//EN';
+               return $vCalendarObject;
+       }
+
+       public function importCalendarObject(int $calendarId, VCalendar $vCalendarObject): void {
+               try {
+                       $this->calDavBackend->createCalendarObject(
+                               $calendarId,
+                               UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT,
+                               $vCalendarObject->serialize(),
+                               CalDavBackend::CALENDAR_TYPE_CALENDAR,
+                       );
+               } catch (BadRequest $e) {
+                       // Rollback creation of calendar on error
+                       $this->calDavBackend->deleteCalendar($calendarId, true);
+               }
+       }
+
+       /**
+        * @throws CalendarMigratorException
+        */
+       public function importCalendar(IUser $user, string $filename, string $initialCalendarUri, VCalendar $vCalendar): void {
+               $principalUri = $this->getPrincipalUri($user);
+               $calendarUri = $this->getUniqueCalendarUri($user, $initialCalendarUri);
+
+               $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
+                       '{DAV:}displayname' => isset($vCalendar->{'X-WR-CALNAME'}) ? $vCalendar->{'X-WR-CALNAME'}->getValue() : $this->l10n->t('Migrated calendar (%1$s)', [$filename]),
+                       '{http://apple.com/ns/ical/}calendar-color' => isset($vCalendar->{'X-APPLE-CALENDAR-COLOR'}) ? $vCalendar->{'X-APPLE-CALENDAR-COLOR'}->getValue() : $this->defaults->getColorPrimary(),
+                       'components' => implode(
+                               ',',
+                               array_reduce(
+                                       $vCalendar->getComponents(),
+                                       function (array $componentNames, VObjectComponent $component) {
+                                               /** @var array<int, string> $componentNames */
+                                               return !in_array($component->name, $componentNames, true)
+                                                       ? [...$componentNames, $component->name]
+                                                       : $componentNames;
+                                       },
+                                       [],
+                               )
+                       ),
+               ]);
+
+               /** @var VObjectComponent[] $calendarComponents */
+               $calendarComponents = array_values(array_filter(
+                       $vCalendar->getComponents(),
+                       // VTIMEZONE components are handled separately and added to the calendar object only if depended on by the component
+                       fn (VObjectComponent $component) => $component->name !== 'VTIMEZONE',
+               ));
+
+               /** @var array<string, VObjectComponent[]> $groupedCalendarComponents */
+               $groupedCalendarComponents = [];
+               /** @var VObjectComponent[] $ungroupedCalendarComponents */
+               $ungroupedCalendarComponents = [];
+
+               foreach ($calendarComponents as $component) {
+                       if (isset($component->UID)) {
+                               $uid = $component->UID->getValue();
+                               // Components with the same UID (e.g. recurring events) are grouped together into a single calendar object
+                               if (isset($groupedCalendarComponents[$uid])) {
+                                       $groupedCalendarComponents[$uid][] = $component;
+                               } else {
+                                       $groupedCalendarComponents[$uid] = [$component];
+                               }
+                       } else {
+                               $ungroupedCalendarComponents[] = $component;
+                       }
+               }
+
+               foreach ($groupedCalendarComponents as $uid => $components) {
+                       // Construct and import a calendar object containing all components of a group
+                       $vCalendarObject = $this->initCalendarObject();
+                       foreach ($components as $component) {
+                               foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
+                                       $vCalendarObject->add($component);
+                               }
+                       }
+                       $this->importCalendarObject($calendarId, $vCalendarObject);
+               }
+
+               foreach ($ungroupedCalendarComponents as $component) {
+                       // Construct and import a calendar object for a single component
+                       $vCalendarObject = $this->initCalendarObject();
+                       foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
+                               $vCalendarObject->add($component);
+                       }
+                       $this->importCalendarObject($calendarId, $vCalendarObject);
+               }
+       }
+
+       /**
+        * @throws FilesystemException
+        * @throws CalendarMigratorException
+        */
+       public function import(IUser $user, string $srcDir, string $filename, OutputInterface $output): void {
+               $userId = $user->getUID();
+
+               try {
+                       /** @var VCalendar $vCalendar */
+                       $vCalendar = VObjectReader::read(
+                               fopen("$srcDir/$filename", 'r'),
+                               VObjectReader::OPTION_FORGIVING,
+                       );
+               } catch (FilesystemException $e) {
+                       throw new FilesystemException("Failed to read file: \"$srcDir/$filename\"");
+               }
+
+               $problems = $vCalendar->validate();
+               if (empty($problems)) {
+                       $splitFilename = explode('_', $filename, 2);
+                       if (count($splitFilename) !== 2) {
+                               $output->writeln("<error>Invalid filename, filename must be of the format: \"<calendar_name>_YYYY-MM-DD" . CalendarMigrator::FILENAME_EXT . "\"</error>");
+                               throw new CalendarMigratorException();
+                       }
+                       [$initialCalendarUri, $suffix] = $splitFilename;
+
+                       $this->importCalendar(
+                               $user,
+                               $filename,
+                               $initialCalendarUri,
+                               $vCalendar,
+                       );
+
+                       $vCalendar->destroy();
+
+                       $output->writeln("<info>✅ Imported calendar \"$filename\" into account of <$userId></info>");
+               } else {
+                       throw new CalendarMigratorException("Invalid data contained in \"$srcDir/$filename\"");
+               }
+       }
+}
diff --git a/apps/dav/lib/UserMigration/CalendarMigratorException.php b/apps/dav/lib/UserMigration/CalendarMigratorException.php
new file mode 100644 (file)
index 0000000..91bac58
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2022 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\DAV\UserMigration;
+
+use Exception;
+
+class CalendarMigratorException extends Exception {
+}
diff --git a/apps/dav/lib/UserMigration/InvalidCalendarException.php b/apps/dav/lib/UserMigration/InvalidCalendarException.php
new file mode 100644 (file)
index 0000000..0e42ef1
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright 2022 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\DAV\UserMigration;
+
+use Exception;
+
+class InvalidCalendarException extends Exception {
+}
index 551870de20e0b970b337d4765608886fbfb71891..f2c94cb5400d6dab34880170525f0992a337b61d 100644 (file)
@@ -39,6 +39,11 @@ interface ICalendar {
         */
        public function getKey();
 
+       /**
+        * @since 24.0.0
+        */
+       public function getUri();
+
        /**
         * In comparison to getKey() this function returns a human readable (maybe translated) name
         * @return null|string