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);
+
+ }
+
+}