Merge pull request #49888 from nextcloud/feat/ocp/meetings-api-requirements
feat(ocp): calendar event builder apipull/50095/head
commit
dd0f7f0bbf
@ -0,0 +1,132 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OC\Calendar; |
||||
|
||||
use DateTimeInterface; |
||||
use InvalidArgumentException; |
||||
use OCP\AppFramework\Utility\ITimeFactory; |
||||
use OCP\Calendar\ICalendarEventBuilder; |
||||
use OCP\Calendar\ICreateFromString; |
||||
use Sabre\VObject\Component\VCalendar; |
||||
use Sabre\VObject\Component\VEvent; |
||||
|
||||
class CalendarEventBuilder implements ICalendarEventBuilder { |
||||
private ?DateTimeInterface $startDate = null; |
||||
private ?DateTimeInterface $endDate = null; |
||||
private ?string $summary = null; |
||||
private ?string $description = null; |
||||
private ?string $location = null; |
||||
private ?array $organizer = null; |
||||
private array $attendees = []; |
||||
|
||||
public function __construct( |
||||
private readonly string $uid, |
||||
private readonly ITimeFactory $timeFactory, |
||||
) { |
||||
} |
||||
|
||||
public function setStartDate(DateTimeInterface $start): ICalendarEventBuilder { |
||||
$this->startDate = $start; |
||||
return $this; |
||||
} |
||||
|
||||
public function setEndDate(DateTimeInterface $end): ICalendarEventBuilder { |
||||
$this->endDate = $end; |
||||
return $this; |
||||
} |
||||
|
||||
public function setSummary(string $summary): ICalendarEventBuilder { |
||||
$this->summary = $summary; |
||||
return $this; |
||||
} |
||||
|
||||
public function setDescription(string $description): ICalendarEventBuilder { |
||||
$this->description = $description; |
||||
return $this; |
||||
} |
||||
|
||||
public function setLocation(string $location): ICalendarEventBuilder { |
||||
$this->location = $location; |
||||
return $this; |
||||
} |
||||
|
||||
public function setOrganizer(string $email, ?string $commonName = null): ICalendarEventBuilder { |
||||
$this->organizer = [$email, $commonName]; |
||||
return $this; |
||||
} |
||||
|
||||
public function addAttendee(string $email, ?string $commonName = null): ICalendarEventBuilder { |
||||
$this->attendees[] = [$email, $commonName]; |
||||
return $this; |
||||
} |
||||
|
||||
public function toIcs(): string { |
||||
if ($this->startDate === null) { |
||||
throw new InvalidArgumentException('Event is missing a start date'); |
||||
} |
||||
|
||||
if ($this->endDate === null) { |
||||
throw new InvalidArgumentException('Event is missing an end date'); |
||||
} |
||||
|
||||
if ($this->summary === null) { |
||||
throw new InvalidArgumentException('Event is missing a summary'); |
||||
} |
||||
|
||||
if ($this->organizer === null && $this->attendees !== []) { |
||||
throw new InvalidArgumentException('Event has attendees but is missing an organizer'); |
||||
} |
||||
|
||||
$vcalendar = new VCalendar(); |
||||
$props = [ |
||||
'UID' => $this->uid, |
||||
'DTSTAMP' => $this->timeFactory->now(), |
||||
'SUMMARY' => $this->summary, |
||||
'DTSTART' => $this->startDate, |
||||
'DTEND' => $this->endDate, |
||||
]; |
||||
if ($this->description !== null) { |
||||
$props['DESCRIPTION'] = $this->description; |
||||
} |
||||
if ($this->location !== null) { |
||||
$props['LOCATION'] = $this->location; |
||||
} |
||||
/** @var VEvent $vevent */ |
||||
$vevent = $vcalendar->add('VEVENT', $props); |
||||
if ($this->organizer !== null) { |
||||
self::addAttendeeToVEvent($vevent, 'ORGANIZER', $this->organizer); |
||||
} |
||||
foreach ($this->attendees as $attendee) { |
||||
self::addAttendeeToVEvent($vevent, 'ATTENDEE', $attendee); |
||||
} |
||||
return $vcalendar->serialize(); |
||||
} |
||||
|
||||
public function createInCalendar(ICreateFromString $calendar): string { |
||||
$fileName = $this->uid . '.ics'; |
||||
$calendar->createFromString($fileName, $this->toIcs()); |
||||
return $fileName; |
||||
} |
||||
|
||||
/** |
||||
* @param array{0: string, 1: ?string} $tuple A tuple of [$email, $commonName] where $commonName may be null. |
||||
*/ |
||||
private static function addAttendeeToVEvent(VEvent $vevent, string $name, array $tuple): void { |
||||
[$email, $cn] = $tuple; |
||||
if (!str_starts_with($email, 'mailto:')) { |
||||
$email = "mailto:$email"; |
||||
} |
||||
$params = []; |
||||
if ($cn !== null) { |
||||
$params['CN'] = $cn; |
||||
} |
||||
$vevent->add($name, $email, $params); |
||||
} |
||||
} |
||||
@ -0,0 +1,110 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace OCP\Calendar; |
||||
|
||||
use DateTimeInterface; |
||||
use InvalidArgumentException; |
||||
use OCP\Calendar\Exceptions\CalendarException; |
||||
|
||||
/** |
||||
* The calendar event builder can be used to conveniently build a calendar event and then serialize |
||||
* it to a ICS string. The ICS string can be submitted to calendar instances implementing the |
||||
* {@see \OCP\Calendar\ICreateFromString} interface. |
||||
* |
||||
* Also note this class can not be injected directly with dependency injection. |
||||
* Instead, inject {@see \OCP\Calendar\IManager} and use |
||||
* {@see \OCP\Calendar\IManager::createEventBuilder()} afterwards. |
||||
* |
||||
* All setters return self to allow chaining method calls. |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
interface ICalendarEventBuilder { |
||||
/** |
||||
* Set the start date, time and time zone. |
||||
* This property is required! |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function setStartDate(DateTimeInterface $start): self; |
||||
|
||||
/** |
||||
* Set the end date, time and time zone. |
||||
* This property is required! |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function setEndDate(DateTimeInterface $end): self; |
||||
|
||||
/** |
||||
* Set the event summary or title. |
||||
* This property is required! |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function setSummary(string $summary): self; |
||||
|
||||
/** |
||||
* Set the event description. |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function setDescription(string $description): self; |
||||
|
||||
/** |
||||
* Set the event location. It can either be a physical address or a URL. |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function setLocation(string $location): self; |
||||
|
||||
/** |
||||
* Set the event organizer. |
||||
* This property is required if attendees are added! |
||||
* |
||||
* The "mailto:" prefix is optional and will be added automatically if it is missing. |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function setOrganizer(string $email, ?string $commonName = null): self; |
||||
|
||||
/** |
||||
* Add a new attendee to the event. |
||||
* Adding at least one attendee requires also setting the organizer! |
||||
* |
||||
* The "mailto:" prefix is optional and will be added automatically if it is missing. |
||||
* |
||||
* @since 31.0.0 |
||||
*/ |
||||
public function addAttendee(string $email, ?string $commonName = null): self; |
||||
|
||||
/** |
||||
* Serialize the built event to an ICS string if all required properties set. |
||||
* |
||||
* @since 31.0.0 |
||||
* |
||||
* @return string The serialized ICS string |
||||
* |
||||
* @throws InvalidArgumentException If required properties were not set |
||||
*/ |
||||
public function toIcs(): string; |
||||
|
||||
/** |
||||
* Create the event in the given calendar. |
||||
* |
||||
* @since 31.0.0 |
||||
* |
||||
* @return string The filename of the created event |
||||
* |
||||
* @throws InvalidArgumentException If required properties were not set |
||||
* @throws CalendarException If writing the event to the calendar fails |
||||
*/ |
||||
public function createInCalendar(ICreateFromString $calendar): string; |
||||
} |
||||
@ -0,0 +1,16 @@ |
||||
BEGIN:VCALENDAR |
||||
VERSION:2.0 |
||||
PRODID:-//Sabre//Sabre VObject 4.5.6//EN |
||||
CALSCALE:GREGORIAN |
||||
BEGIN:VEVENT |
||||
UID:event-uid-123 |
||||
DTSTAMP:20250105T000000Z |
||||
SUMMARY:My event |
||||
DTSTART:20250105T170958Z |
||||
DTEND:20250105T171958Z |
||||
DESCRIPTION:Foo bar baz |
||||
ORGANIZER:mailto:organizer@domain.tld |
||||
ATTENDEE:mailto:attendee1@domain.tld |
||||
ATTENDEE:mailto:attendee2@domain.tld |
||||
END:VEVENT |
||||
END:VCALENDAR |
||||
@ -0,0 +1,2 @@ |
||||
SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
SPDX-License-Identifier: AGPL-3.0-or-later |
||||
@ -0,0 +1,13 @@ |
||||
BEGIN:VCALENDAR |
||||
VERSION:2.0 |
||||
PRODID:-//Sabre//Sabre VObject 4.5.6//EN |
||||
CALSCALE:GREGORIAN |
||||
BEGIN:VEVENT |
||||
UID:event-uid-123 |
||||
DTSTAMP:20250105T000000Z |
||||
SUMMARY:My event |
||||
DTSTART:20250105T170958Z |
||||
DTEND:20250105T171958Z |
||||
DESCRIPTION:Foo bar baz |
||||
END:VEVENT |
||||
END:VCALENDAR |
||||
@ -0,0 +1,2 @@ |
||||
SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
SPDX-License-Identifier: AGPL-3.0-or-later |
||||
@ -0,0 +1,146 @@ |
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
/** |
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors |
||||
* SPDX-License-Identifier: AGPL-3.0-or-later |
||||
*/ |
||||
|
||||
namespace Test\Calendar; |
||||
|
||||
use DateTimeImmutable; |
||||
use InvalidArgumentException; |
||||
use OC\Calendar\CalendarEventBuilder; |
||||
use OCP\AppFramework\Utility\ITimeFactory; |
||||
use OCP\Calendar\ICreateFromString; |
||||
use PHPUnit\Framework\MockObject\MockObject; |
||||
use Test\TestCase; |
||||
|
||||
class CalendarEventBuilderTest extends TestCase { |
||||
private CalendarEventBuilder $calendarEventBuilder; |
||||
private ITimeFactory&MockObject $timeFactory; |
||||
|
||||
protected function setUp(): void { |
||||
parent::setUp(); |
||||
|
||||
$this->timeFactory = $this->createMock(ITimeFactory::class); |
||||
$this->timeFactory->method('now') |
||||
->willReturn(new DateTimeImmutable('20250105T000000Z')); |
||||
|
||||
$this->calendarEventBuilder = new CalendarEventBuilder( |
||||
'event-uid-123', |
||||
$this->timeFactory, |
||||
); |
||||
} |
||||
|
||||
public function testToIcs(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->setOrganizer('mailto:organizer@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); |
||||
|
||||
$expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-complete.ics'); |
||||
$actual = $this->calendarEventBuilder->toIcs(); |
||||
$this->assertEquals($expected, $actual); |
||||
} |
||||
|
||||
public function testToIcsWithoutOrganizerAndAttendees(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
|
||||
$expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-without-attendees.ics'); |
||||
$actual = $this->calendarEventBuilder->toIcs(); |
||||
$this->assertEquals($expected, $actual); |
||||
} |
||||
|
||||
public function testToIcsWithoutMailtoPrefix(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('attendee2@domain.tld'); |
||||
|
||||
$expected = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-complete.ics'); |
||||
$actual = $this->calendarEventBuilder->toIcs(); |
||||
$this->assertEquals($expected, $actual); |
||||
} |
||||
|
||||
public function testCreateInCalendar(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); |
||||
|
||||
$expectedIcs = file_get_contents(\OC::$SERVERROOT . '/tests/data/ics/event-builder-complete.ics'); |
||||
$calendar = $this->createMock(ICreateFromString::class); |
||||
$calendar->expects(self::once()) |
||||
->method('createFromString') |
||||
->with('event-uid-123.ics', $expectedIcs); |
||||
|
||||
$actual = $this->calendarEventBuilder->createInCalendar($calendar); |
||||
$this->assertEquals('event-uid-123.ics', $actual); |
||||
} |
||||
|
||||
public function testToIcsWithoutStartDate(): void { |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); |
||||
|
||||
$this->expectException(InvalidArgumentException::class); |
||||
$this->expectExceptionMessageMatches('/start date/i'); |
||||
$this->calendarEventBuilder->toIcs(); |
||||
} |
||||
|
||||
public function testToIcsWithoutEndDate(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); |
||||
|
||||
$this->expectException(InvalidArgumentException::class); |
||||
$this->expectExceptionMessageMatches('/end date/i'); |
||||
$this->calendarEventBuilder->toIcs(); |
||||
} |
||||
|
||||
public function testToIcsWithoutSummary(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->setOrganizer('organizer@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); |
||||
|
||||
$this->expectException(InvalidArgumentException::class); |
||||
$this->expectExceptionMessageMatches('/summary/i'); |
||||
$this->calendarEventBuilder->toIcs(); |
||||
} |
||||
|
||||
public function testToIcsWithoutOrganizerWithAttendees(): void { |
||||
$this->calendarEventBuilder->setStartDate(new DateTimeImmutable('2025-01-05T17:09:58Z')); |
||||
$this->calendarEventBuilder->setEndDate(new DateTimeImmutable('2025-01-05T17:19:58Z')); |
||||
$this->calendarEventBuilder->setSummary('My event'); |
||||
$this->calendarEventBuilder->setDescription('Foo bar baz'); |
||||
$this->calendarEventBuilder->addAttendee('attendee1@domain.tld'); |
||||
$this->calendarEventBuilder->addAttendee('mailto:attendee2@domain.tld'); |
||||
|
||||
$this->expectException(InvalidArgumentException::class); |
||||
$this->expectExceptionMessageMatches('/organizer/i'); |
||||
$this->calendarEventBuilder->toIcs(); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue