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 20, 2025
1 parent f0145fe commit c7abca8
Show file tree
Hide file tree
Showing 6 changed files with 707 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
161 changes: 161 additions & 0 deletions lib/Command/Export.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Command;

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) {
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
[email protected]
ATTENDEE;RSVP=TRUE;CN=Jenna Stocks;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL;
ROLE=REQ-PARTICIPANT;LANGUAGE=en_GB;SCHEDULE-STATUS=1.1:mailto:jenna.stock
[email protected]
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
[email protected]
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) {

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

InvalidArgument

lib/Command/Export.php:154:42: 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 154 in lib/Command/Export.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

InvalidArgument

lib/Command/Export.php:154:42: 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)
$output->writeln($chunk);
}
}

return self::SUCCESS;
}
}
102 changes: 102 additions & 0 deletions lib/Command/Import.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Calendar\Command;

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 52 in lib/Command/Import.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-stable30

UndefinedClass

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

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

View workflow job for this annotation

GitHub Actions / static-psalm-analysis dev-master

UndefinedClass

lib/Command/Import.php:52:28: 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.");
} 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;
}
}
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-master

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-master

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)

}
}
Loading

0 comments on commit c7abca8

Please sign in to comment.