add option to invite users to new calendar entry

This commit is contained in:
stefan.tollkuehn committed 2026-10-22 18:19:12 +02:00
1 parent 8910a46d1d
commit 2070e9c390
7 files changed
+396 -13

No files matched your search

+8 -4
View File
@@ -72,7 +72,8 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.atocal-row select { .atocal-row select,
.atocal-row input[type="text"] {
align-self: center; align-self: center;
width: 100%; width: 100%;
max-width: 280px; max-width: 280px;
@@ -123,14 +124,16 @@
border-color: var(--color-primary-element); border-color: var(--color-primary-element);
} }
.atocal-row select:hover { .atocal-row select:hover,
.atocal-row input[type="text"]:hover {
border-color: var(--color-primary-element); border-color: var(--color-primary-element);
} }
/* Keyboard navigation must stay visible */ /* Keyboard navigation must stay visible */
.atocal-actions button:focus-visible, .atocal-actions button:focus-visible,
.atocal-actions a:focus-visible, .atocal-actions a:focus-visible,
.atocal-row select:focus-visible { .atocal-row select:focus-visible,
.atocal-row input[type="text"]:focus-visible {
outline: 2px solid var(--color-primary-element); outline: 2px solid var(--color-primary-element);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -152,7 +155,8 @@
padding: 12px 0; padding: 12px 0;
} }
.atocal-row select { .atocal-row select,
.atocal-row input[type="text"] {
max-width: none; max-width: none;
} }
+5 -1
View File
@@ -30,7 +30,11 @@
"Invalid organizer email address.": "Ungültige E-Mail-Adresse des Organisators.", "Invalid organizer email address.": "Ungültige E-Mail-Adresse des Organisators.",
"No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "In deinem Konto wurde kein beschreibbarer Kalender gefunden. Erstelle einen in der Nextcloud-Kalender-App und versuche es erneut.", "No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "In deinem Konto wurde kein beschreibbarer Kalender gefunden. Erstelle einen in der Nextcloud-Kalender-App und versuche es erneut.",
"The selected calendar is no longer writable. Pick another one and try again.": "Der ausgewählte Kalender ist nicht mehr beschreibbar. Wähle einen anderen aus und versuche es erneut.", "The selected calendar is no longer writable. Pick another one and try again.": "Der ausgewählte Kalender ist nicht mehr beschreibbar. Wähle einen anderen aus und versuche es erneut.",
"The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuche es erneut." "The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuche es erneut.",
"Invitees": "Teilnehmer",
"Email addresses, comma-separated": "E-Mail-Adressen, durch Kommas getrennt",
"%s is not a valid email address.": "%s ist keine gültige E-Mail-Adresse.",
"A meeting can invite at most %d people.": "Ein Meeting kann höchstens %d Personen einladen."
}, },
"pluralForms": "nplurals=2; plural=(n != 1);" "pluralForms": "nplurals=2; plural=(n != 1);"
} }
+5 -1
View File
@@ -30,7 +30,11 @@
"Invalid organizer email address.": "Ungültige E-Mail-Adresse des Organisators.", "Invalid organizer email address.": "Ungültige E-Mail-Adresse des Organisators.",
"No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "In Ihrem Konto wurde kein beschreibbarer Kalender gefunden. Erstellen Sie einen in der Nextcloud-Kalender-App und versuchen Sie es erneut.", "No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "In Ihrem Konto wurde kein beschreibbarer Kalender gefunden. Erstellen Sie einen in der Nextcloud-Kalender-App und versuchen Sie es erneut.",
"The selected calendar is no longer writable. Pick another one and try again.": "Der ausgewählte Kalender ist nicht mehr beschreibbar. Wählen Sie einen anderen aus und versuchen Sie es erneut.", "The selected calendar is no longer writable. Pick another one and try again.": "Der ausgewählte Kalender ist nicht mehr beschreibbar. Wählen Sie einen anderen aus und versuchen Sie es erneut.",
"The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuchen Sie es erneut." "The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"Invitees": "Teilnehmer",
"Email addresses, comma-separated": "E-Mail-Adressen, durch Kommas getrennt",
"%s is not a valid email address.": "%s ist keine gültige E-Mail-Adresse.",
"A meeting can invite at most %d people.": "Ein Meeting kann höchstens %d Personen einladen."
}, },
"pluralForms": "nplurals=2; plural=(n != 1);" "pluralForms": "nplurals=2; plural=(n != 1);"
} }
+5 -1
View File
@@ -30,7 +30,11 @@
"Invalid organizer email address.": "Invalid organizer email address.", "Invalid organizer email address.": "Invalid organizer email address.",
"No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.", "No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.",
"The selected calendar is no longer writable. Pick another one and try again.": "The selected calendar is no longer writable. Pick another one and try again.", "The selected calendar is no longer writable. Pick another one and try again.": "The selected calendar is no longer writable. Pick another one and try again.",
"The event could not be created. Please try again.": "The event could not be created. Please try again." "The event could not be created. Please try again.": "The event could not be created. Please try again.",
"Invitees": "Invitees",
"Email addresses, comma-separated": "Email addresses, comma-separated",
"%s is not a valid email address.": "%s is not a valid email address.",
"A meeting can invite at most %d people.": "A meeting can invite at most %d people."
}, },
"pluralForms": "nplurals=2; plural=(n != 1);" "pluralForms": "nplurals=2; plural=(n != 1);"
} }
+174 -5
View File
@@ -14,6 +14,7 @@ use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\Calendar\ICreateFromString; use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager; use OCP\Calendar\IManager;
use OCP\Contacts\IManager as IContactsManager;
use OCP\Constants; use OCP\Constants;
use OCP\IRequest; use OCP\IRequest;
use OCP\IURLGenerator; use OCP\IURLGenerator;
@@ -63,7 +64,19 @@ class CalendarController extends Controller
*/ */
private const MAX_EMAIL_LENGTH = 254; private const MAX_EMAIL_LENGTH = 254;
/*
* Maximum number of people a meeting may invite.
*/
private const MAX_ATTENDEES = 20;
/*
* Address book entries offered as autocomplete options,
* keeps the confirm page small on large address books.
*/
private const MAX_CONTACT_SUGGESTIONS = 50;
private IManager $calendarManager; private IManager $calendarManager;
private IContactsManager $contactsManager;
private IAppManager $appManager; private IAppManager $appManager;
private IUserSession $userSession; private IUserSession $userSession;
private IURLGenerator $urlGenerator; private IURLGenerator $urlGenerator;
@@ -75,6 +88,7 @@ class CalendarController extends Controller
string $appName, string $appName,
IRequest $request, IRequest $request,
IManager $calendarManager, IManager $calendarManager,
IContactsManager $contactsManager,
IAppManager $appManager, IAppManager $appManager,
IUserSession $userSession, IUserSession $userSession,
IURLGenerator $urlGenerator, IURLGenerator $urlGenerator,
@@ -85,6 +99,7 @@ class CalendarController extends Controller
parent::__construct($appName, $request); parent::__construct($appName, $request);
$this->calendarManager = $calendarManager; $this->calendarManager = $calendarManager;
$this->contactsManager = $contactsManager;
$this->appManager = $appManager; $this->appManager = $appManager;
$this->userSession = $userSession; $this->userSession = $userSession;
$this->urlGenerator = $urlGenerator; $this->urlGenerator = $urlGenerator;
@@ -137,7 +152,10 @@ class CalendarController extends Controller
return $this->renderConfirm( return $this->renderConfirm(
$data, $data,
$calendars, $calendars,
$this->defaultCalendarId($calendars) $this->defaultCalendarId($calendars),
'',
'',
$this->contactSuggestions()
); );
} }
@@ -156,7 +174,8 @@ class CalendarController extends Controller
string $organizer = '', string $organizer = '',
string $location = '', string $location = '',
string $url = '', string $url = '',
string $calendarId = '' string $calendarId = '',
string $attendees = ''
): RedirectResponse|TemplateResponse { ): RedirectResponse|TemplateResponse {
try { try {
$data = $this->validateEventData( $data = $this->validateEventData(
@@ -180,6 +199,34 @@ class CalendarController extends Controller
$calendars = $this->findWritableCalendars($user->getUID()); $calendars = $this->findWritableCalendars($user->getUID());
/*
* Invitees: the input must be rejected before the event
* is written, but the user input survives a re-render.
*/
try {
$data['attendees'] = $this->parseAttendees($attendees);
} catch (\InvalidArgumentException $e) {
return $this->renderConfirm(
$data,
$calendars,
$calendarId,
$e->getMessage(),
$attendees,
$this->contactSuggestions()
);
}
/*
* Display names from the address book end up as ATTENDEE
* parameter CN in the event.
*/
if ($data['attendees'] !== []) {
$data['attendees'] = $this->withContactNames(
$data['attendees'],
$this->contactSuggestions()
);
}
if ($calendarId === '') { if ($calendarId === '') {
$calendarId = $this->defaultCalendarId($calendars); $calendarId = $this->defaultCalendarId($calendars);
} }
@@ -203,7 +250,9 @@ class CalendarController extends Controller
: $this->l10n->t( : $this->l10n->t(
'The selected calendar is no longer writable. ' 'The selected calendar is no longer writable. '
. 'Pick another one and try again.' . 'Pick another one and try again.'
) ),
$attendees,
$this->contactSuggestions()
); );
} }
@@ -225,7 +274,9 @@ class CalendarController extends Controller
$calendarId, $calendarId,
$this->l10n->t( $this->l10n->t(
'The event could not be created. Please try again.' 'The event could not be created. Please try again.'
) ),
$attendees,
$this->contactSuggestions()
); );
} }
@@ -346,7 +397,9 @@ class CalendarController extends Controller
array $data, array $data,
array $calendars, array $calendars,
string $calendarId = '', string $calendarId = '',
string $error = '' string $error = '',
string $attendees = '',
array $contacts = []
): TemplateResponse { ): TemplateResponse {
/* /*
* Stylesheets are not auto-loaded, css/style.css * Stylesheets are not auto-loaded, css/style.css
@@ -377,6 +430,8 @@ class CalendarController extends Controller
$calendars $calendars
), ),
'calendarId' => $calendarId, 'calendarId' => $calendarId,
'attendees' => $attendees,
'contacts' => $contacts,
'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'), 'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'),
'createUrl' => $this->urlGenerator->linkToRoute( 'createUrl' => $this->urlGenerator->linkToRoute(
Application::APP_ID . '.calendar.create' Application::APP_ID . '.calendar.create'
@@ -385,6 +440,104 @@ class CalendarController extends Controller
); );
} }
/**
* Attendee emails from the invite input, comma-, semicolon-
* or whitespace-separated. Lowercased and deduplicated.
* An invalid entry aborts the request.
*
* @return list<array{email: string, name: string}>
*/
private function parseAttendees(string $input): array
{
$attendees = [];
$seen = [];
foreach (preg_split('/[\s,;]+/', trim($input)) ?: [] as $part) {
$email = strtolower(trim($part));
if ($email === '' || isset($seen[$email])) {
continue;
}
if (count($seen) >= self::MAX_ATTENDEES) {
throw new \InvalidArgumentException(
$this->l10n->t(
'A meeting can invite at most %d people.',
[self::MAX_ATTENDEES]
)
);
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new \InvalidArgumentException(
$this->l10n->t(
'%s is not a valid email address.',
[$part]
)
);
}
$seen[$email] = true;
$attendees[] = ['email' => $email, 'name' => ''];
}
return $attendees;
}
/**
* Address book entries with an email address, offered as
* autocomplete options by the invite input.
*
* @return list<array{email: string, name: string}>
*/
private function contactSuggestions(): array
{
$contacts = [];
$results = $this->contactsManager->search('', ['FN', 'EMAIL']);
foreach ($results ?? [] as $result) {
$name = (string)($result['FN'] ?? '');
$emails = $result['EMAIL'] ?? [];
$emails = is_array($emails) ? $emails : [$emails];
foreach ($emails as $email) {
$email = trim((string)$email);
if ($email !== ''
&& count($contacts) < self::MAX_CONTACT_SUGGESTIONS) {
$contacts[] = ['email' => $email, 'name' => $name];
}
}
}
return $contacts;
}
/**
* Fill attendee display names from the address book.
* First match wins, emails are case-insensitive.
*
* @param list<array{email: string, name: string}> $attendees
* @param list<array{email: string, name: string}> $contacts
* @return list<array{email: string, name: string}>
*/
private function withContactNames(array $attendees, array $contacts): array
{
$namesByEmail = [];
foreach ($contacts as $contact) {
$namesByEmail[strtolower($contact['email'])] ??= $contact['name'];
}
foreach ($attendees as $i => $attendee) {
$attendees[$i]['name'] = $namesByEmail[$attendee['email']] ?? '';
}
return $attendees;
}
/** /**
* Create the event through the platform event builder, * Create the event through the platform event builder,
* which handles escaping and ICS serialization. * which handles escaping and ICS serialization.
@@ -423,6 +576,22 @@ class CalendarController extends Controller
'TRIGGER' => self::REMINDER_TRIGGER, 'TRIGGER' => self::REMINDER_TRIGGER,
]); ]);
/*
* Attendee invitation: the CalDAV backend schedules
* (IMIP/iTIP emails) based on these properties.
*/
foreach ($data['attendees'] as $attendee) {
$parameters = $attendee['name'] !== ''
? ['CN' => $attendee['name']]
: [];
$vobject->VEVENT->add(
'ATTENDEE',
'mailto:' . $attendee['email'],
$parameters
);
}
$fileName = (string) $vobject->VEVENT->UID . '.ics'; $fileName = (string) $vobject->VEVENT->UID . '.ics';
$calendar->createFromString($fileName, $vobject->serialize()); $calendar->createFromString($fileName, $vobject->serialize());
+27
View File
@@ -129,6 +129,33 @@ $l = $_['l10n'];
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="atocal-row">
<label for="atocal-attendees">
<strong><?= $h($l->t('Invitees')) ?></strong>
</label>
<span>
<input
id="atocal-attendees"
name="attendees"
type="text"
list="atocal-contact-emails"
value="<?= $h($_['attendees']) ?>"
placeholder="<?= $h(
$l->t('Email addresses, comma-separated')
) ?>"
>
<?php if ($_['contacts'] !== []): ?>
<datalist id="atocal-contact-emails">
<?php foreach ($_['contacts'] as $contact): ?>
<option
value="<?= $h($contact['email']) ?>"
><?= $h($contact['name']) ?></option>
<?php endforeach; ?>
</datalist>
<?php endif; ?>
</span>
</div>
<input <input
type="hidden" type="hidden"
name="requesttoken" name="requesttoken"
+172 -1
View File
@@ -13,6 +13,7 @@ use OCP\Calendar\ICalendarEventBuilder;
use OCP\Calendar\ICreateFromString; use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager; use OCP\Calendar\IManager;
use OCP\Constants; use OCP\Constants;
use OCP\Contacts\IManager as IContactsManager;
use OCP\IDateTimeZone; use OCP\IDateTimeZone;
use OCP\IL10N; use OCP\IL10N;
use OCP\IRequest; use OCP\IRequest;
@@ -29,6 +30,7 @@ require_once __DIR__ . '/OCUtilStub.php';
class CalendarControllerTest extends TestCase class CalendarControllerTest extends TestCase
{ {
private IManager&MockObject $calendarManager; private IManager&MockObject $calendarManager;
private IContactsManager&MockObject $contactsManager;
private IAppManager&MockObject $appManager; private IAppManager&MockObject $appManager;
private IUserSession&MockObject $userSession; private IUserSession&MockObject $userSession;
private IURLGenerator&MockObject $urlGenerator; private IURLGenerator&MockObject $urlGenerator;
@@ -37,6 +39,7 @@ class CalendarControllerTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
$this->calendarManager = $this->createMock(IManager::class); $this->calendarManager = $this->createMock(IManager::class);
$this->contactsManager = $this->createMock(IContactsManager::class);
$this->appManager = $this->createMock(IAppManager::class); $this->appManager = $this->createMock(IAppManager::class);
$this->userSession = $this->createMock(IUserSession::class); $this->userSession = $this->createMock(IUserSession::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class); $this->urlGenerator = $this->createMock(IURLGenerator::class);
@@ -50,13 +53,16 @@ class CalendarControllerTest extends TestCase
$l10n = $this->createMock(IL10N::class); $l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback( $l10n->method('t')->willReturnCallback(
static fn (string $text) => $text static fn (string $text, array $params = []): string => $params === []
? $text
: vsprintf($text, $params)
); );
return new CalendarController( return new CalendarController(
'atocal', 'atocal',
$this->createMock(IRequest::class), $this->createMock(IRequest::class),
$this->calendarManager, $this->calendarManager,
$this->contactsManager,
$this->appManager, $this->appManager,
$this->userSession, $this->userSession,
$this->urlGenerator, $this->urlGenerator,
@@ -484,6 +490,60 @@ class CalendarControllerTest extends TestCase
'organizer' => '', 'organizer' => '',
'location' => '', 'location' => '',
'url' => '', 'url' => '',
'attendees' => [],
];
$this->invoke($c, 'createEvent', [$calendar, $data]);
}
/*
* createEvent: ATTENDEE injection
*/
public function testCreateEventInvitesAttendees(): void
{
$builder = $this->createMock(ICalendarEventBuilder::class);
$builder->method('toIcs')->willReturn(
"BEGIN:VCALENDAR\r\n" .
"VERSION:2.0\r\n" .
"PRODID:-//Nextcloud//AToCal//EN\r\n" .
"BEGIN:VEVENT\r\n" .
"UID:test-uid-123\r\n" .
"SUMMARY:Weekly sync\r\n" .
"END:VEVENT\r\n" .
"END:VCALENDAR\r\n"
);
$this->calendarManager->method('createEventBuilder')->willReturn($builder);
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
$calendar->expects($this->once())->method('createFromString')
->willReturnCallback(function (string $name, string $data): bool {
$this->assertStringContainsString(
'ATTENDEE;CN=Alice Example:mailto:alice@example.com',
$data
);
$this->assertStringContainsString(
'ATTENDEE:mailto:bob@example.com',
$data
);
return true;
});
$c = $this->newController();
$data = [
'summary' => 'Weekly sync',
'description' => '',
'start' => new \DateTimeImmutable('2026-10-07T14:15:00 UTC'),
'end' => new \DateTimeImmutable('2026-10-07T15:15:00 UTC'),
'organizer' => '',
'location' => '',
'url' => '',
'attendees' => [
['email' => 'alice@example.com', 'name' => 'Alice Example'],
['email' => 'bob@example.com', 'name' => ''],
],
]; ];
$this->invoke($c, 'createEvent', [$calendar, $data]); $this->invoke($c, 'createEvent', [$calendar, $data]);
@@ -727,4 +787,115 @@ class CalendarControllerTest extends TestCase
\OC_Util::$styles \OC_Util::$styles
); );
} }
/*
* parseAttendees()
*/
public function testParseAttendeesNormalizesInput(): void
{
$c = $this->newController();
$attendees = $this->invoke($c, 'parseAttendees', [
'alice@example.com, BOB@Example.com ; bob@example.com carl@example.com',
]);
$this->assertSame([
['email' => 'alice@example.com', 'name' => ''],
['email' => 'bob@example.com', 'name' => ''],
['email' => 'carl@example.com', 'name' => ''],
], $attendees);
}
public function testParseAttendeesEmptyInput(): void
{
$c = $this->newController();
$this->assertSame(
[],
$this->invoke($c, 'parseAttendees', [' '])
);
}
public function testParseAttendeesRejectsInvalidEmail(): void
{
$c = $this->newController();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('is not a valid email address.');
$this->invoke($c, 'parseAttendees', ['alice@example.com,not-an-email']);
}
public function testParseAttendeesEnforcesLimit(): void
{
$c = $this->newController();
$emails = implode(',', array_map(
static fn (int $i): string => "person$i@example.com",
range(1, 21)
));
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('A meeting can invite at most 20 people.');
$this->invoke($c, 'parseAttendees', [$emails]);
}
/*
* confirm(): contact suggestions for the datalist
*/
public function testConfirmOffersContactSuggestions(): void
{
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
$this->setUpWritableCalendars();
$this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/');
$this->contactsManager->method('search')->willReturn([
['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'],
['FN' => 'Bob', 'EMAIL' => ['bob@example.com', 'bob@work.example.com']],
]);
$c = $this->newController();
$response = $c->confirm(
'Weekly sync',
'',
'2026-10-07T14:15',
'2026-10-07T15:15'
);
$this->assertSame('confirm', $response->getTemplateName());
$this->assertSame([
['email' => 'alice@example.com', 'name' => 'Alice Example'],
['email' => 'bob@example.com', 'name' => 'Bob'],
['email' => 'bob@work.example.com', 'name' => 'Bob'],
], $response->getParams()['contacts']);
}
/*
* create(): invalid invitee input keeps the form usable
*/
public function testCreateInvalidAttendeeRendersConfirm(): void
{
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
$this->setUpWritableCalendars();
$args = $this->validCreateArgs();
$args[] = 'alice@example.com, not-an-email';
$c = $this->newController();
$response = $c->create(...$args);
$this->assertSame('confirm', $response->getTemplateName());
$params = $response->getParams();
$this->assertStringContainsString(
'is not a valid email address.',
$params['error']
);
$this->assertSame('alice@example.com, not-an-email', $params['attendees']);
$this->assertSame('5::personal', $params['calendarId']);
}
} }