From 320079f6e64818b6d6f735181b39f9c7a8fbddf6 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Mon, 30 Dec 2024 15:52:29 -0500 Subject: [PATCH] feat: OCC and OCS Calendar Import/Export Signed-off-by: SebastianKrupinski --- appinfo/info.xml | 4 + lib/Command/Export.php | 80 ++++++++++ lib/Command/Import.php | 103 +++++++++++++ lib/Controller/ExportController.php | 90 +++++++++++ lib/Service/Export/ExportService.php | 102 +++++++++++++ lib/Service/Import/ImportService.php | 219 +++++++++++++++++++++++++++ lib/Service/Import/TextImporter.php | 123 +++++++++++++++ lib/Service/Import/XmlImporter.php | 168 ++++++++++++++++++++ 8 files changed, 889 insertions(+) create mode 100644 lib/Command/Export.php create mode 100644 lib/Command/Import.php create mode 100644 lib/Controller/ExportController.php create mode 100644 lib/Service/Export/ExportService.php create mode 100644 lib/Service/Import/ImportService.php create mode 100644 lib/Service/Import/TextImporter.php create mode 100644 lib/Service/Import/XmlImporter.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 024fb7091..abe3eef08 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -47,6 +47,10 @@ OCA\Calendar\BackgroundJob\CleanUpOutdatedBookingsJob + + OCA\Calendar\Command\Import + OCA\Calendar\Command\Export + calendar diff --git a/lib/Command/Export.php b/lib/Command/Export.php new file mode 100644 index 000000000..55a4a4f71 --- /dev/null +++ b/lib/Command/Export.php @@ -0,0 +1,80 @@ +setName('calendar:export') + ->setDescription('Export a specific calendar for a user') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('cid', InputArgument::REQUIRED, 'Id of calendar') + ->addArgument('format', InputArgument::OPTIONAL, 'Format of output (iCal, jCal, xCal) default to iCal') + ->addArgument('location', InputArgument::OPTIONAL, 'location of where to write the output. defaults to stdout'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('cid'); + $format = $input->getArgument('format'); + $location = $input->getArgument('location'); + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found."); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + throw new InvalidArgumentException("Calendar <$calendarId> dose support this function"); + } + // evaluate if requested format is supported + if ($format !== null && !in_array($format, $this->exportService::FORMATS)) { + throw new InvalidArgumentException("Format <$format> is not valid."); + } elseif ($format === null) { + $format = 'ical'; + } + // evaluate is a valid location was given and is usable otherwise output to stdout + if ($location !== null) { + $handle = fopen($location, "w"); + if ($handle === false) { + throw new InvalidArgumentException("Location <$location> is not valid. Can not open location for write operation."); + } else { + foreach ($this->exportService->export($calendar, $format) as $chunk) { + fwrite($handle, $chunk); + } + fclose($handle); + } + } else { + foreach ($this->exportService->export($calendar, $format) as $chunk) { + $output->writeln($chunk); + } + } + + return self::SUCCESS; + } +} diff --git a/lib/Command/Import.php b/lib/Command/Import.php new file mode 100644 index 000000000..0f423cdda --- /dev/null +++ b/lib/Command/Import.php @@ -0,0 +1,103 @@ +setName('calendar:import') + ->setDescription('Import a file or stream') + ->addArgument('uid', InputArgument::REQUIRED, 'Id of system user') + ->addArgument('cid', InputArgument::REQUIRED, 'Id of calendar') + ->addArgument('format', InputArgument::OPTIONAL, 'Format of output (iCal, jCal, xCal) default to iCal') + ->addArgument('location', InputArgument::OPTIONAL, 'location of where to write the output. defaults to stdin'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + + $userId = $input->getArgument('uid'); + $calendarId = $input->getArgument('cid'); + $format = $input->getArgument('format'); + $location = $input->getArgument('location'); + + if (!$this->userManager->userExists($userId)) { + throw new InvalidArgumentException("User <$userId> not found."); + } + // retrieve calendar and evaluate if import is supported and writeable + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + throw new InvalidArgumentException("Calendar <$calendarId> not found."); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarImport) { + throw new InvalidArgumentException("Calendar <$calendarId> dose support this function"); + } + if (!$calendar->isWritable()) { + throw new InvalidArgumentException("Calendar <$calendarId> is not writeable"); + } + if ($calendar->isDeleted()) { + throw new InvalidArgumentException("Calendar <$calendarId> is deleted"); + } + // construct settings object + $settings = new CalendarImportSettings(); + // evaluate if provided format is supported + if ($format !== null && !in_array($format, $this->importService::FORMATS)) { + throw new \InvalidArgumentException("Format <$format> is not valid."); + } else { + $settings->format = $format ?? 'ical'; + } + // evaluate if a valid location was given and is usable otherwise default to stdin + if ($location !== null) { + $input = fopen($location, "r"); + if ($input === false) { + throw new \InvalidArgumentException("Location <$location> is not valid. Can not open location for read operation."); + } else { + try { + $outcome = $this->importService->import($input, $calendar, $settings); + } finally { + fclose($input); + } + } + } else { + $input = fopen('php://stdin', 'r'); + if ($input === false) { + throw new \InvalidArgumentException("Can not open stdin for read operation."); + } else { + try { + $temp = tmpfile(); + while (!feof($input)) { + fwrite($temp, fread($input, 8192)); + } + fseek($temp, 0); + $outcome = $this->importService->import($temp, $calendar, $settings); + } finally { + fclose($input); + fclose($temp); + } + } + } + + return self::SUCCESS; + } +} diff --git a/lib/Controller/ExportController.php b/lib/Controller/ExportController.php new file mode 100644 index 000000000..6d4f67916 --- /dev/null +++ b/lib/Controller/ExportController.php @@ -0,0 +1,90 @@ +userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($userId !== null) { + if (!$this->groupManager->isAdmin($this->userSession->getUser()->getUID()) && + $this->userSession->getUser()->getUID() !== $userId) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($userId)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + /* + if ($calendar instanceof ICalendarExport) { + return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST); + } + */ + // evaluate if requested format is supported and convert to output content type + if ($format !== null && !in_array($format, $this->exportService::FORMATS)) { + return new DataResponse(['error' => 'format invalid'], Http::STATUS_BAD_REQUEST); + } elseif ($format === null) { + $format = 'ical'; + } + $contentType = match (strtolower($format)) { + 'jcal' => 'application/calendar+json; charset=UTF-8', + 'xcal' => 'application/calendar+xml; charset=UTF-8', + default => 'text/calendar; charset=UTF-8' + }; + + return new StreamGeneratorResponse($this->exportService->export($calendar, $format), $contentType); + + } +} diff --git a/lib/Service/Export/ExportService.php b/lib/Service/Export/ExportService.php new file mode 100644 index 000000000..4af7d84db --- /dev/null +++ b/lib/Service/Export/ExportService.php @@ -0,0 +1,102 @@ +exportStart($format); + + // iterate through each returned vCalendar entry + // extract each component except timezones, convert to appropriate format and output + // extract any timezones and save them but do not output + $timezones = []; + foreach ($calendar->export($range) as $entry) { + $consecutive = false; + foreach ($entry->getComponents() as $vComponent) { + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } else { + yield $this->exportObject($vComponent, $format, $consecutive); + $consecutive = true; + } + } + } + // iterate through each vTimezone entry, convert to appropriate format and output + foreach ($timezones as $vComponent) { + yield $this->exportObject($vComponent, $format, $consecutive); + $consecutive = true; + } + + yield $this->exportFinish($format); + + } + + /** + * Generates appropriate output start based on selected format + */ + private function exportStart(string $format): string { + return match ($format) { + 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar App\/\/EN"]],[', + 'xcal' => '2.0-//IDN nextcloud.com//Calendar App//EN', + default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar App//EN\n" + }; + } + + /** + * Generates appropriate output end based on selected format + */ + private function exportFinish(string $format): string { + return match ($format) { + 'jcal' => ']]', + 'xcal' => '', + default => "END:VCALENDAR\n" + }; + } + + /** + * Generates appropriate output content for a component based on selected format + */ + private function exportObject(Component $vobject, string $format, bool $consecutive): string { + return match ($format) { + 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject), + 'xcal' => $this->exportObjectXml($vobject), + default => Writer::write($vobject) + }; + } + + /** + * Generates appropriate output content for a component for xml format + */ + private function exportObjectXml(Component $vobject): string { + $writer = new \Sabre\Xml\Writer(); + $writer->openMemory(); + $writer->setIndent(false); + $vobject->xmlSerialize($writer); + return $writer->outputMemory(); + } + +} diff --git a/lib/Service/Import/ImportService.php b/lib/Service/Import/ImportService.php new file mode 100644 index 000000000..8edad8a06 --- /dev/null +++ b/lib/Service/Import/ImportService.php @@ -0,0 +1,219 @@ +source = $source; + + $time_start = microtime(true); + + switch ($settings->format) { + case 'ical': + $calendar->import($settings, $this->importText(...)); + break; + case 'jcal': + $calendar->import($settings, $this->importJson(...)); + break; + case 'xcal': + $calendar->import($settings, $this->importXml(...)); + break; + } + + $time_end = microtime(true); + + $execution_time = ($time_end - $time_start); + + echo "\nProcessing time: " . $execution_time; + echo "\n"; + + } + + private function importText(CalendarImportSettings $settings): Generator { + + $importer = new TextImporter($this->source); + + $structure = $importer->structure(); + + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + + // calendar properties + foreach ($structure['VCALENDAR'] as $entry) { + $sObjectPrefix .= $entry; + if (!substr($entry, -1) === "\n" || !substr($entry, -2) === "\r\n") { + $sObjectPrefix .= PHP_EOL; + } + } + + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $sObjectContents = $importer->extract($instance[2], $instance[3]); + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + + // calendar components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + // extract all instances of component and unserialize to object + $sObjectContents = ""; + foreach ($instances as $instance) { + $sObjectContents .= $importer->extract($instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + // extract all timezones from properties for all instances + $vObjectTZ = []; + foreach ($vObject->getComponents() as $vComponent) { + if ($vComponent->name !== 'VTIMEZONE') { + foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) { + if (isset($vComponent->$property?->parameters['TZID'])) { + $tid = $vComponent->$property->parameters['TZID']->getValue(); + if (isset($timezones[$tid]) && !isset($vObjectTZ[$tid])) { + $vObjectTZ[$tid] = clone $timezones[$tid]; + } + } + } + } + } + // add time zones to object + foreach ($vObjectTZ as $zone) { + $vObject->add($zone); + } + // return object + yield $vObject; + + } + } + } + + private function importXml(CalendarImportSettings $settings): Generator { + + $importer = new XmlImporter($this->source); + + $structure = $importer->structure(); + + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $sObjectContents = $importer->extract($instance[2], $instance[3]); + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + + // calendar components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + // extract all instances of component and unserialize to object + $sObjectContents = ""; + foreach ($instances as $instance) { + $sObjectContents .= $importer->extract($instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + // extract all timezones from properties for all instances + $vObjectTZ = []; + foreach ($vObject->getComponents() as $vComponent) { + if ($vComponent->name !== 'VTIMEZONE') { + foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) { + if (isset($vComponent->$property?->parameters['TZID'])) { + $tid = $vComponent->$property->parameters['TZID']->getValue(); + if (isset($timezones[$tid]) && !isset($vObjectTZ[$tid])) { + $vObjectTZ[$tid] = clone $timezones[$tid]; + } + } + } + } + } + // add time zones to object + foreach ($vObjectTZ as $zone) { + $vObject->add($zone); + } + // return object + yield $vObject; + + } + } + + } + + private function importJson(CalendarImportSettings $settings): Generator { + + $importer = Reader::readJson($this->source); + + // calendar time zones + $timezones = []; + foreach ($importer->VTIMEZONE as $timezone) { + $tzid = $timezone->TZID?->getValue(); + if ($tzid !== null) { + $timezones[$tzid] = clone $timezone; + } + } + + // calendar components + foreach ($importer->getBaseComponents() as $base) { + /** @var VCalendar $vObject */ + $vObject = new VCalendar; + $vObject->VERSION = clone $importer->VERSION; + $vObject->PRODID = clone $importer->PRODID; + // extract all instances of component + foreach ($importer->getByUID($base->UID->getValue()) as $instance) { + $vObject->add(clone $instance); + } + // extract all timezones from properties for all instances + $vObjectTZ = []; + foreach ($vObject->getComponents() as $vComponent) { + if ($vComponent->name !== 'VTIMEZONE') { + foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) { + if (isset($vComponent->$property?->parameters['TZID'])) { + $tid = $vComponent->$property->parameters['TZID']->getValue(); + if (isset($timezones[$tid]) && !isset($vObjectTZ[$tid])) { + $vObjectTZ[$tid] = clone $timezones[$tid]; + } + } + } + } + } + // add time zones to object + foreach ($vObjectTZ as $zone) { + $vObject->add($zone); + } + // return object + yield $vObject; + + } + } + +} diff --git a/lib/Service/Import/TextImporter.php b/lib/Service/Import/TextImporter.php new file mode 100644 index 000000000..6aa480da7 --- /dev/null +++ b/lib/Service/Import/TextImporter.php @@ -0,0 +1,123 @@ + [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + + public function __construct( + protected $source + ) { + //Ensure that the $data var is of the right type + if (!is_string($source) && (!is_resource($source) || get_resource_type($source) !== 'stream')) { + throw new Exception('Source must be a string or a stream resource'); + } + } + + protected function analyze() { + + $componentStart = null; + $componentEnd = null; + $componentId = null; + $componentType = null; + + fseek($this->source, 0); + while(!feof($this->source)) { + $data = fgets($this->source); + + if ($data === false || empty(trim($data))) { + continue; + } + + if (ctype_space($data[0]) === false) { + + if (str_starts_with($data, 'BEGIN:')) { + $type = trim(substr($data, 6)); + if (in_array($type, $this->types)) { + $componentStart = ftell($this->source) - strlen($data); + $componentType = $type; + } + unset($type); + } + + if (str_starts_with($data, 'END:')) { + $type = trim(substr($data, 4)); + if ($componentType === $type) { + $componentEnd = ftell($this->source); + } + unset($type); + } + + if ($componentStart !== null && str_starts_with($data, 'UID:')) { + $componentId = trim(substr($data, 5)); + } + + if ($componentStart !== null && str_starts_with($data, 'TZID:')) { + $componentId = trim(substr($data, 5)); + } + + } + + if ($componentStart === null) { + if (!str_starts_with($data, 'BEGIN:VCALENDAR') && !str_starts_with($data, 'END:VCALENDAR')) { + $components['VCALENDAR'][] = $data; + } + } + + if ($componentEnd !== null) { + if ($componentId !== null) { + $this->structure[$componentType][$componentId][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } else { + $this->structure[$componentType][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } + $componentId = null; + $componentType = null; + $componentStart = null; + $componentEnd = null; + } + + } + + } + + public function structure(): array { + + if (!$this->analyzed) { + $this->analyze(); + } + + return $this->structure; + } + + public function extract(int $start, int $end): string { + + fseek($this->source, $start); + return fread($this->source, $end - $start); + + } + +} diff --git a/lib/Service/Import/XmlImporter.php b/lib/Service/Import/XmlImporter.php new file mode 100644 index 000000000..b8e82b319 --- /dev/null +++ b/lib/Service/Import/XmlImporter.php @@ -0,0 +1,168 @@ +'; + public const OBJECT_SUFFIX = ''; + + protected array $types = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE']; + protected bool $analyzed = false; + protected array $structure = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + protected int $praseLevel = 0; + protected array $prasePath = []; + protected ?int $componentStart = null; + protected ?int $componentEnd = null; + protected int $componentLevel = 0; + protected ?string $componentId = null; + protected ?string $componentType = null; + protected bool $componentIdProperty = false; + + + public function __construct( + protected $source + ) { + //Ensure that the $data var is of the right type + if (!is_string($source) && (!is_resource($source) || get_resource_type($source) !== 'stream')) { + throw new Exception('Source must be a string or a stream resource'); + } + } + + protected function analyze() { + + $this->praseLevel = 0; + $this->prasePath = []; + $this->componentStart = null; + $this->componentEnd = null; + $this->componentLevel = 0; + $this->componentId = null; + $this->componentType = null; + $this->componentIdProperty = false; + //Create the parser + $parser = xml_parser_create(); + // assign handlers + xml_set_object($parser, $this); + xml_set_element_handler($parser, $this->tagStart(...), $this->tagEnd(...)); + xml_set_default_handler($parser, $this->tagContents(...)); + //If the data is a resource then loop through it, otherwise just parse the string + if (is_resource($this->source)) { + //Not all resources support fseek. For those that don't, suppress the error + @fseek($this->source, 0); + while ($chunk = fread($this->source, 4096)) { + if (!xml_parse($parser, $chunk, feof($this->source))) { + throw new Exception( + xml_error_string(xml_get_error_code($parser)) + .' At line: '. + xml_get_current_line_number($parser) + ); + } + } + } else { + if (!xml_parse($parser, $this->source, true)) { + throw new Exception( + xml_error_string(xml_get_error_code($parser)) + .' At line: '. + xml_get_current_line_number($parser) + ); + } + } + + //Free up the parser + xml_parser_free($parser); + + } + + protected function tagStart($parser, $tag, $attributes) { + + $this->praseLevel++; + $this->prasePath[$this->praseLevel] = $tag; + + if (in_array($tag, $this->types)) { + $this->componentStart = xml_get_current_byte_index($parser) - (strlen($tag) + 1); + $this->componentType = $tag; + $this->componentLevel = $this->praseLevel; + } + + if ($this->componentStart !== null && + ($this->componentLevel + 2) === $this->praseLevel && + ($tag === 'UID' || $tag === 'TZID') + ) { + $this->componentIdProperty = true; + } + + return $parser; + } + + protected function tagEnd($parser, $tag) { + + if ($tag === 'UID' || $tag === 'TZID') { + $this->componentIdProperty = false; + } elseif ($this->componentType === $tag) { + $this->componentEnd = xml_get_current_byte_index($parser); + + if ($this->componentId !== null) { + $this->structure[$this->componentType][$this->componentId][] = [ + $this->componentType, + $this->componentId, + $this->componentStart, + $this->componentEnd, + implode('/', $this->prasePath) + ]; + } else { + $this->structure[$this->componentType][] = [ + $this->componentType, + $this->componentId, + $this->componentStart, + $this->componentEnd, + implode('/', $this->prasePath) + ]; + } + $this->componentStart = null; + $this->componentEnd = null; + $this->componentId = null; + $this->componentType = null; + $this->componentIdProperty = false; + } + + unset($this->prasePath[$this->praseLevel]); + $this->praseLevel--; + + return $parser; + } + + protected function tagContents($parser, $data) { + + if ($this->componentIdProperty) { + $this->componentId = $data; + } + + return $parser; + } + + + public function structure(): array { + + if (!$this->analyzed) { + $this->analyze(); + } + + return $this->structure; + } + + public function extract(int $start, int $end): string { + + fseek($this->source, $start); + return fread($this->source, $end - $start); + + } + +}