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;
}
.atocal-row select {
.atocal-row select,
.atocal-row input[type="text"] {
align-self: center;
width: 100%;
max-width: 280px;
@@ -123,14 +124,16 @@
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);
}
/* Keyboard navigation must stay visible */
.atocal-actions button: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-offset: 2px;
}
@@ -152,7 +155,8 @@
padding: 12px 0;
}
.atocal-row select {
.atocal-row select,
.atocal-row input[type="text"] {
max-width: none;
}
+5 -1
View File
@@ -30,7 +30,11 @@
"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.",
"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);"
}
+5 -1
View File
@@ -30,7 +30,11 @@
"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.",
"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);"
}
+5 -1
View File
@@ -30,7 +30,11 @@
"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.",
"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);"
}
+174 -5
View File
@@ -14,6 +14,7 @@ use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager;
use OCP\Contacts\IManager as IContactsManager;
use OCP\Constants;
use OCP\IRequest;
use OCP\IURLGenerator;
@@ -63,7 +64,19 @@ class CalendarController extends Controller
*/
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 IContactsManager $contactsManager;
private IAppManager $appManager;
private IUserSession $userSession;
private IURLGenerator $urlGenerator;
@@ -75,6 +88,7 @@ class CalendarController extends Controller
string $appName,
IRequest $request,
IManager $calendarManager,
IContactsManager $contactsManager,
IAppManager $appManager,
IUserSession $userSession,
IURLGenerator $urlGenerator,
@@ -85,6 +99,7 @@ class CalendarController extends Controller
parent::__construct($appName, $request);
$this->calendarManager = $calendarManager;
$this->contactsManager = $contactsManager;
$this->appManager = $appManager;
$this->userSession = $userSession;
$this->urlGenerator = $urlGenerator;
@@ -137,7 +152,10 @@ class CalendarController extends Controller
return $this->renderConfirm(
$data,
$calendars,
$this->defaultCalendarId($calendars)
$this->defaultCalendarId($calendars),
'',
'',
$this->contactSuggestions()
);
}
@@ -156,7 +174,8 @@ class CalendarController extends Controller
string $organizer = '',
string $location = '',
string $url = '',
string $calendarId = ''
string $calendarId = '',
string $attendees = ''
): RedirectResponse|TemplateResponse {
try {
$data = $this->validateEventData(
@@ -180,6 +199,34 @@ class CalendarController extends Controller
$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 === '') {
$calendarId = $this->defaultCalendarId($calendars);
}
@@ -203,7 +250,9 @@ class CalendarController extends Controller
: $this->l10n->t(
'The selected calendar is no longer writable. '
. 'Pick another one and try again.'
)
),
$attendees,
$this->contactSuggestions()
);
}
@@ -225,7 +274,9 @@ class CalendarController extends Controller
$calendarId,
$this->l10n->t(
'The event could not be created. Please try again.'
)
),
$attendees,
$this->contactSuggestions()
);
}
@@ -346,7 +397,9 @@ class CalendarController extends Controller
array $data,
array $calendars,
string $calendarId = '',
string $error = ''
string $error = '',
string $attendees = '',
array $contacts = []
): TemplateResponse {
/*
* Stylesheets are not auto-loaded, css/style.css
@@ -377,6 +430,8 @@ class CalendarController extends Controller
$calendars
),
'calendarId' => $calendarId,
'attendees' => $attendees,
'contacts' => $contacts,
'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'),
'createUrl' => $this->urlGenerator->linkToRoute(
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,
* which handles escaping and ICS serialization.
@@ -423,6 +576,22 @@ class CalendarController extends Controller
'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';
$calendar->createFromString($fileName, $vobject->serialize());
+27
View File
@@ -129,6 +129,33 @@ $l = $_['l10n'];
</div>
<?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
type="hidden"
name="requesttoken"
+172 -1
View File
@@ -13,6 +13,7 @@ use OCP\Calendar\ICalendarEventBuilder;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager;
use OCP\Constants;
use OCP\Contacts\IManager as IContactsManager;
use OCP\IDateTimeZone;
use OCP\IL10N;
use OCP\IRequest;
@@ -29,6 +30,7 @@ require_once __DIR__ . '/OCUtilStub.php';
class CalendarControllerTest extends TestCase
{
private IManager&MockObject $calendarManager;
private IContactsManager&MockObject $contactsManager;
private IAppManager&MockObject $appManager;
private IUserSession&MockObject $userSession;
private IURLGenerator&MockObject $urlGenerator;
@@ -37,6 +39,7 @@ class CalendarControllerTest extends TestCase
protected function setUp(): void
{
$this->calendarManager = $this->createMock(IManager::class);
$this->contactsManager = $this->createMock(IContactsManager::class);
$this->appManager = $this->createMock(IAppManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
@@ -50,13 +53,16 @@ class CalendarControllerTest extends TestCase
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback(
static fn (string $text) => $text
static fn (string $text, array $params = []): string => $params === []
? $text
: vsprintf($text, $params)
);
return new CalendarController(
'atocal',
$this->createMock(IRequest::class),
$this->calendarManager,
$this->contactsManager,
$this->appManager,
$this->userSession,
$this->urlGenerator,
@@ -484,6 +490,60 @@ class CalendarControllerTest extends TestCase
'organizer' => '',
'location' => '',
'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]);
@@ -727,4 +787,115 @@ class CalendarControllerTest extends TestCase
\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']);
}
}