Skip to content

Commit

Permalink
feat: OCC and OCS Calendar Import/Export
Browse files Browse the repository at this point in the history
Signed-off-by: SebastianKrupinski <[email protected]>
  • Loading branch information
SebastianKrupinski committed Jan 26, 2025
1 parent aa6bff3 commit 320079f
Show file tree
Hide file tree
Showing 8 changed files with 889 additions and 0 deletions.
4 changes: 4 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
<background-jobs>
<job>OCA\Calendar\BackgroundJob\CleanUpOutdatedBookingsJob</job>
</background-jobs>
<commands>
<command>OCA\Calendar\Command\Import</command>
<command>OCA\Calendar\Command\Export</command>
</commands>
<navigations>
<navigation>
<id>calendar</id>
Expand Down
80 changes: 80 additions & 0 deletions lib/Command/Export.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Command;

use InvalidArgumentException;
use OCA\Calendar\Service\Export\ExportService;
use OCP\Calendar\ICalendarExport;
use OCP\Calendar\IManager;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class Export extends Command {
public function __construct(
private IUserManager $userManager,
private IManager $calendarManager,
private ExportService $exportService,
) {
parent::__construct();
}

protected function configure(): void {
$this->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) {

Check failure on line 52 in lib/Command/Export.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Command/Export.php:52:29: UndefinedClass: Class, interface or enum named OCP\Calendar\ICalendarExport does not exist (see https://psalm.dev/019)

Check failure on line 52 in lib/Command/Export.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Command/Export.php:52:29: UndefinedClass: Class, interface or enum named OCP\Calendar\ICalendarExport does not exist (see https://psalm.dev/019)
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;
}
}
103 changes: 103 additions & 0 deletions lib/Command/Import.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Command;

use InvalidArgumentException;
use OCA\Calendar\Service\Import\ImportService;
use OCP\Calendar\CalendarImportSettings;
use OCP\Calendar\ICalendarImport;
use OCP\Calendar\IManager;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class Import extends Command {
public function __construct(
private IUserManager $userManager,
private IManager $calendarManager,
private ImportService $importService,
) {
parent::__construct();
}

protected function configure(): void {
$this->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) {

Check failure on line 53 in lib/Command/Import.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Command/Import.php:53:29: UndefinedClass: Class, interface or enum named OCP\Calendar\ICalendarImport does not exist (see https://psalm.dev/019)

Check failure on line 53 in lib/Command/Import.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Command/Import.php:53:29: UndefinedClass: Class, interface or enum named OCP\Calendar\ICalendarImport does not exist (see https://psalm.dev/019)
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;
}
}
90 changes: 90 additions & 0 deletions lib/Controller/ExportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Controller;

use OCA\Calendar\AppInfo\Application;
use OCA\Calendar\Service\Export\ExportService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\StreamGeneratorResponse;
use OCP\Calendar\ICalendarExport;
use OCP\Calendar\IManager;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;

class ExportController extends Controller {

public function __construct(
IRequest $request,
private IUserSession $userSession,
private IUserManager $userManager,
private IGroupManager $groupManager,
private IManager $calendarManager,
private ExportService $exportService
) {
parent::__construct(Application::APP_ID, $request);
}

#[ApiRoute(verb: 'GET', url: '/export', root: '/calendar')]
#[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')]
#[UserRateLimit(limit: 1, period: 60)]
#[NoAdminRequired]
public function index(string $id, ?string $fmt = null, ?string $user = null) {

$userId = $user;
$calendarId = $id;
$format = $fmt;
// evaluate if user is logged in and has permissions
if (!$this->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);

Check failure on line 87 in lib/Controller/ExportController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Controller/ExportController.php:87:14: UndefinedClass: Class, interface or enum named OCP\AppFramework\Http\StreamGeneratorResponse does not exist (see https://psalm.dev/019)

Check failure on line 87 in lib/Controller/ExportController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

InvalidArgument

lib/Controller/ExportController.php:87:67: InvalidArgument: Argument 1 of OCA\Calendar\Service\Export\ExportService::export expects OCP\Calendar\ICalendarExport&OCP\Calendar\ICalendar, but OCP\Calendar\ICalendar provided (see https://psalm.dev/004)

Check failure on line 87 in lib/Controller/ExportController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Controller/ExportController.php:87:14: UndefinedClass: Class, interface or enum named OCP\AppFramework\Http\StreamGeneratorResponse does not exist (see https://psalm.dev/019)

Check failure on line 87 in lib/Controller/ExportController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

InvalidArgument

lib/Controller/ExportController.php:87:67: InvalidArgument: Argument 1 of OCA\Calendar\Service\Export\ExportService::export expects OCP\Calendar\ICalendarExport&OCP\Calendar\ICalendar, but OCP\Calendar\ICalendar provided (see https://psalm.dev/004)

}
}
102 changes: 102 additions & 0 deletions lib/Service/Export/ExportService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Service\Export;

use Generator;
use OCP\Calendar\CalendarExportRange;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarExport;
use Sabre\VObject\Component;
use Sabre\VObject\Writer;

class ExportService {

public const FORMATS = ['ical', 'jcal', 'xcal'];

public function __construct() {}

/**
* Retrieves calendar object, converts each object to appropriate format and streams output
*/
public function export(ICalendar&ICalendarExport $calendar, string $format, ?CalendarExportRange $range = null): Generator {

Check failure on line 27 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Service/Export/ExportService.php:27:25: UndefinedClass: Class, interface or enum named OCP\Calendar\ICalendarExport does not exist (see https://psalm.dev/019)

Check failure on line 27 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Service/Export/ExportService.php:27:78: UndefinedClass: Class, interface or enum named OCP\Calendar\CalendarExportRange does not exist (see https://psalm.dev/019)

Check failure on line 27 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Service/Export/ExportService.php:27:25: UndefinedClass: Class, interface or enum named OCP\Calendar\ICalendarExport does not exist (see https://psalm.dev/019)

Check failure on line 27 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Service/Export/ExportService.php:27:78: UndefinedClass: Class, interface or enum named OCP\Calendar\CalendarExportRange does not exist (see https://psalm.dev/019)

yield $this->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' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar App//EN</text></prodid></properties><components>',
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' => '</components></vcalendar></icalendar>',
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 {

Check failure on line 83 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Service/Export/ExportService.php:83:32: UndefinedClass: Class, interface or enum named Sabre\VObject\Component does not exist (see https://psalm.dev/019)

Check failure on line 83 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Service/Export/ExportService.php:83:32: UndefinedClass: Class, interface or enum named Sabre\VObject\Component does not exist (see https://psalm.dev/019)
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 {

Check failure on line 94 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

lib/Service/Export/ExportService.php:94:35: UndefinedClass: Class, interface or enum named Sabre\VObject\Component does not exist (see https://psalm.dev/019)

Check failure on line 94 in lib/Service/Export/ExportService.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable31

UndefinedClass

lib/Service/Export/ExportService.php:94:35: UndefinedClass: Class, interface or enum named Sabre\VObject\Component does not exist (see https://psalm.dev/019)
$writer = new \Sabre\Xml\Writer();
$writer->openMemory();
$writer->setIndent(false);
$vobject->xmlSerialize($writer);
return $writer->outputMemory();
}

}
Loading

0 comments on commit 320079f

Please sign in to comment.