diff --git a/css/style.css b/css/style.css index e3152fc..2518e7d 100644 --- a/css/style.css +++ b/css/style.css @@ -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; } diff --git a/l10n/de.json b/l10n/de.json index 1826aa5..3b56935 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -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);" } diff --git a/l10n/de_DE.json b/l10n/de_DE.json index 386dfca..0ad7ce6 100644 --- a/l10n/de_DE.json +++ b/l10n/de_DE.json @@ -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);" } diff --git a/l10n/en.json b/l10n/en.json index 5de1e44..ff099f6 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -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);" } diff --git a/lib/Controller/CalendarController.php b/lib/Controller/CalendarController.php index bafada0..4790504 100644 --- a/lib/Controller/CalendarController.php +++ b/lib/Controller/CalendarController.php @@ -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 + */ + 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 + */ + 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 $attendees + * @param list $contacts + * @return list + */ + 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()); diff --git a/templates/confirm.php b/templates/confirm.php index ee15c8b..9cee79e 100644 --- a/templates/confirm.php +++ b/templates/confirm.php @@ -129,6 +129,33 @@ $l = $_['l10n']; +
+ + + + + + + + + + + +
+ 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']); + } }