add option to invite users to new calendar entry
This commit is contained in:
1 parent
8910a46d1d
commit
2070e9c390
7 files changed
+396
-13
No files matched your search
+8
-4
@@ -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
@@ -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
@@ -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
@@ -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);"
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user