diff --git a/appinfo/info.xml b/appinfo/info.xml index 71fadf8cf..bb0096647 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..7c77e961b --- /dev/null +++ b/lib/Command/Export.php @@ -0,0 +1,161 @@ +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 { + + fwrite($handle, "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar App//EN\n"); + + + for ($i=0; $i < 16384; $i++) { + + $id = uniqid(); + fwrite( + $handle, +'BEGIN:VEVENT +CREATED:20240910T123608Z +LAST-MODIFIED:20240916T075225Z +DTSTAMP:20240916T075225Z +UID:' . $id . PHP_EOL . +'SUMMARY:Brainstorming workshop- Collectives - Room A +STATUS:CONFIRMED +ORGANIZER;CN=Irina Mikhaylina;SCHEDULE-STATUS=1.1:mailto:irina.mikhaylina@n + extcloud.com +ATTENDEE;RSVP=TRUE;CN=Jonas Meurer;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL; + ROLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:jonas.meurer@n + extcloud.com +ATTENDEE;RSVP=TRUE;CN=Simon Lindner;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL + ;ROLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:simon.lindner + @nextcloud.com +ATTENDEE;RSVP=TRUE;CN=Louis Chemineau;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDU + AL;ROLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:louis.chemi + neau@nextcloud.com +ATTENDEE;RSVP=TRUE;CN=Jenna Stocks;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL; + ROLE=REQ-PARTICIPANT;LANGUAGE=en_GB;SCHEDULE-STATUS=1.1:mailto:jenna.stock + s@nextcloud.com +ATTENDEE;RSVP=TRUE;CN=Marcel Hibbe;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL; + ROLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:marcel.hibbe@n + extcloud.com +ATTENDEE;RSVP=TRUE;CN=Peter Mocanu;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL; + ROLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:peter.mocanu@n + extcloud.com +ATTENDEE;RSVP=TRUE;CN=Cyprien Edouard;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDU + AL;ROLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:cyprien.edo + uard@nextcloud.com +ATTENDEE;RSVP=TRUE;CN=Kim Pohlmann;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL; + ROLE=REQ-PARTICIPANT;LANGUAGE=de;SCHEDULE-STATUS=1.1:mailto:kim.pohlmann@n + extcloud.com +ATTENDEE;RSVP=TRUE;CN=Tobias Kaminsky;PARTSTAT=ACCEPTED;CUTYPE=INDIVIDUAL;R + OLE=REQ-PARTICIPANT;LANGUAGE=en;SCHEDULE-STATUS=1.1:mailto:tobias.kaminsky + @nextcloud.com +DTSTART;TZID=Europe/Berlin:20240919T100000 +DTEND;TZID=Europe/Berlin:20240919T110000 +SEQUENCE:4 +LOCATION:Room A (ground floor) +DESCRIPTION:Hello dear team\, \n \nwe would like to invite you to join ou + r upcoming Product Brainstorming Session\, where we will come together in + a group of 10 people to spark creativity and collaborate on potential new + features for Nextcloud apps 💥 Here is the structure of the session:\n\ + n1. Introduction (5 minutes): We\'ll start with a brief introduction from t + he design team\, followed by participants introducing themselves. To make + things fun and relaxed\, we\'ll choose a moderator from the group. We’ll + just ask for volunteers on the spot\, so no one feels pressured or assigne + d without their agreement.\n\n2. App Demo (10 minutes): Next\, the develop + er of each team will share their screen and give a short demo of the app\, + walking everyone through its current features and explaining what it does + .\n\n3. Idea Generation (10 minutes): After the demo\, it\'s time for every + one to jot down ideas for new features or improvements they\'d like to see. + You can come up with up to 5 ideas and stick them on a whiteboard for us + all to review.\n\n4. Discussion (25 minutes): The moderator will then go t + hrough each idea on the board. The person who wrote it will explain their + thought process\, and then we’ll open the floor for feedback. Everyone c + an share whether they agree\, disagree\, or offer praise and suggestions.\ + n\n5. Prioritization (10 minutes): We’ll pick the most important ideas b + ased on our discussion.\n\nThroughout the session\, the moderator will kee + p track of time and ensure we don’t spend too long on any one idea 🤓 + \n\nIf you have any questions\, feel free to reach out with me!\n\nKind re + gards\nIrina +X-MOZ-GENERATION:1 +END:VEVENT +' + ); + } + fwrite($handle, "END:VCALENDAR\n"); + /* + 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..4786623fd --- /dev/null +++ b/lib/Command/Import.php @@ -0,0 +1,102 @@ +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."); + } elseif ($format === null) { + $settings->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 { + $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); + $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..473b81459 --- /dev/null +++ b/lib/Service/Export/ExportService.php @@ -0,0 +1,103 @@ +exportStart($format); + + // iterate through each returned vCalendar entry + // extract each vObject type, convert to appropriate format and output + // extract any vTimezones objects 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') { + yield $this->exportObject($vComponent, $format, $consecutive); + $consecutive = true; + } + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } + } + } + // 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..0f1c70f5a --- /dev/null +++ b/lib/Service/Import/ImportService.php @@ -0,0 +1,247 @@ +format) { + case 'ical': + $this->importICal($stream, $calendar, $settings); + break; + case 'jcal': + $this->importICal($stream, $calendar, $settings); + break; + case 'xcal': + $this->importICal($stream, $calendar, $settings); + break; + } + + $time_end = microtime(true); + + $execution_time = ($time_end - $time_start); + + echo "\nProcessing time: " . $execution_time; + echo "\n"; + + } + + private function importICal($stream, ICalendar $calendar, CalendarImportSettings $settings): void { + + $outcome = []; + $structure = $this->analyzeICal($stream); + + $vStart = "BEGIN:VCALENDAR" . PHP_EOL; + $vEnd = PHP_EOL . "END:VCALENDAR"; + + // calendar properties + foreach ($structure['VCALENDAR'] as $entry) { + $vStart .= $entry; + if (!substr($entry, -1) === "\n" || !substr($entry, -2) === "\r\n") { + $vStart .= PHP_EOL; + } + } + + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $vData = $this->extractICalData($stream, $instance[2], $instance[3]); + $vObject = Reader::read($vStart . $vData . $vEnd); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + + // calendar components + $components = []; + foreach ($structure['VEVENT'] as $cid => $collection) { + // extract and unserialize components + $vData = ""; + foreach ($collection as $instance) { + $vData .= $this->extractICalData($stream, $instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::read($vStart . $vData . $vEnd); + // 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); + } + // validate object + if ($settings->validate !== 0) { + $issues = $this->validateComponent($vObject, true, 3); + } else { + $issues = []; + } + if ($settings->validate === 1 && $issues !== []) { + $outcome[$cid] = $issues; + continue; + } + if ($settings->validate === 2 && $issues !== []) { + throw new Exception('Error importing calendar object <' . $cid . '> ' . $issues[0]); + } + // add objects to collection until max batch size is reached + if ($settings->bulk > count($components)) { + $components[] = $vObject; + } + // save collection to storage if max batch size is reached + if ($settings->bulk <= count($components)) { + $calendar->import($settings, ...$components); + $components = []; + } + } + + // save collection to storage if max bulk was not reached + if (count($components) > 0) { + $calendar->import($settings, ...$components); + $components = []; + } + + } + + private function validateComponent(VCalendar $vObject, bool $repair, int $level): array { + // validate component(S) + $issues = $vObject->validate(Node::PROFILE_CALDAV); + // attempt to repair + if ($repair && count($issues) > 0) { + $issues = $vObject->validate(Node::REPAIR); + } + // filter out messages based on level + $result = []; + foreach ($issues as $key => $issue) { + if (isset($issue['level']) && $issue['level'] >= $level) { + $result[] = $issue['message']; + } + } + + return $result; + } + + private function analyzeICal($stream): array { + + $tags = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE']; + + $components = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + $componentStart = null; + $componentEnd = null; + $componentId = null; + $componentType = null; + + fseek($stream, 0); + while(!feof($stream)) { + $data = fgets($stream); + + 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, $tags)) { + $componentStart = ftell($stream) - strlen($data); + $componentType = $type; + } + unset($type); + } + + if (str_starts_with($data, 'END:')) { + $type = trim(substr($data, 4)); + if ($componentType === $type) { + $componentEnd = ftell($stream); + } + 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) { + $components[$componentType][$componentId][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } else { + $components[$componentType][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } + $componentId = null; + $componentType = null; + $componentStart = null; + $componentEnd = null; + } + + } + + return $components; + } + + private function extractICalData($stream, int $start, $end): string { + + fseek($stream, $start); + return fread($stream, $end - $start); + + } + +}