From df2d51b66631c5149e5a4d7db73c0ccad60bb6fc Mon Sep 17 00:00:00 2001 From: Jonas Heinrich Date: Fri, 4 Aug 2023 16:17:17 +0200 Subject: [PATCH] feat: Ability to invite circles This commit adds support for adding circles as attendees to a calendar event. The relationship between the imported group and members will be compliant with the iCal specification. A circle with the title "testcircle" will be added as an attendee with iCal attributes "CUTYPE=GROUP` and uri "mailto:circle+CIRCLEID@CIRCLE_INSTANCE". Members of the circle will be imported as standard attendees. Each member gets assigned to the circle group entry by assigning them to the group uri using the iCal member property: "MEMBER='mailto:circle+CIRCLEID@CIRCLE_INSTANCE'". Searching for circles is only enabled if the circles app is activated. Circles added to the list of attendees get imported only once and are not synced yet. While adding a circle, a notice about this is shown to the user. Only members of local circles which are local users get imported. Rendering groups in the frontend is done in a separate PR https://github.com/nextcloud/calendar/pull/5396 Signed-off-by: Jonas Heinrich --- appinfo/routes.php | 7 +- lib/Controller/ContactController.php | 79 +++++++++++++- lib/Controller/ViewController.php | 14 +++ lib/Service/ServiceException.php | 8 ++ psalm.xml | 7 ++ .../Editor/Invitees/InviteesList.vue | 50 ++++----- .../Editor/Invitees/InviteesListSearch.vue | 83 ++++++++++++++- src/services/circleService.js | 100 ++++++++++++++++++ src/services/isCirclesEnabled.js | 28 +++++ src/store/calendarObjectInstance.js | 5 +- .../unit/Controller/ContactControllerTest.php | 12 ++- .../unit/Controller/ViewControllerTest.php | 28 +++-- 12 files changed, 377 insertions(+), 44 deletions(-) create mode 100644 lib/Service/ServiceException.php create mode 100644 src/services/circleService.js create mode 100644 src/services/isCirclesEnabled.js diff --git a/appinfo/routes.php b/appinfo/routes.php index 533b426e4..26ff1deb7 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -5,9 +5,12 @@ declare(strict_types=1); * Calendar App * * @author Georg Ehrke - * @copyright 2018 Georg Ehrke * @author Thomas Müller + * @author Jonas Heinrich + * + * @copyright 2018 Georg Ehrke * @copyright 2016 Thomas Müller + * @copyright 2023 Jonas Heinrich * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE @@ -54,6 +57,8 @@ return [ ['name' => 'contact#searchAttendee', 'url' => '/v1/autocompletion/attendee', 'verb' => 'POST'], ['name' => 'contact#searchLocation', 'url' => '/v1/autocompletion/location', 'verb' => 'POST'], ['name' => 'contact#searchPhoto', 'url' => '/v1/autocompletion/photo', 'verb' => 'POST'], + // Circles + ['name' => 'contact#getCircleMembers', 'url' => '/v1/circles/getmembers', 'verb' => 'GET'], // Settings ['name' => 'settings#setConfig', 'url' => '/v1/config/{key}', 'verb' => 'POST'], // Tools diff --git a/lib/Controller/ContactController.php b/lib/Controller/ContactController.php index 34fce6f51..c7b6d9acd 100644 --- a/lib/Controller/ContactController.php +++ b/lib/Controller/ContactController.php @@ -7,10 +7,12 @@ declare(strict_types=1); * @author Georg Ehrke * @author Jakob Röhrl * @author Christoph Wurst + * @author Jonas Heinrich * * @copyright 2019 Georg Ehrke * @copyright 2019 Jakob Röhrl * @copyright 2019 Christoph Wurst + * @copyright 2023 Jonas Heinrich * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE @@ -28,11 +30,16 @@ declare(strict_types=1); */ namespace OCA\Calendar\Controller; +use OCA\Calendar\Service\ServiceException; +use OCA\Circles\Exceptions\CircleNotFoundException; +use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\QueryException; use OCP\Contacts\IManager; use OCP\IRequest; +use OCP\IUserManager; /** * Class ContactController @@ -43,6 +50,12 @@ class ContactController extends Controller { /** @var IManager */ private $contactsManager; + /** @var IAppManager */ + private $appManager; + + /** @var IUserManager */ + private $userManager; + /** * ContactController constructor. * @@ -52,9 +65,13 @@ class ContactController extends Controller { */ public function __construct(string $appName, IRequest $request, - IManager $contacts) { + IManager $contacts, + IAppManager $appManager, + IUserManager $userManager) { parent::__construct($appName, $request); $this->contactsManager = $contacts; + $this->appManager = $appManager; + $this->userManager = $userManager; } /** @@ -173,6 +190,66 @@ class ContactController extends Controller { return new JSONResponse($contacts); } + /** + * Query members of a circle by circleId + * + * @param string $circleId CircleId to query for members + * @return JSONResponse + * @throws Exception + * @throws \OCP\AppFramework\QueryException + * + * @NoAdminRequired + */ + public function getCircleMembers(string $circleId):JSONResponse { + if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { + return new JSONResponse(); + } + if (!$this->contactsManager->isEnabled()) { + return new JSONResponse(); + } + + try { + $circle = \OCA\Circles\Api\v1\Circles::detailsCircle($circleId, true); + } catch (QueryException $ex) { + return new JSONResponse(); + } catch (CircleNotFoundException $ex) { + return new JSONResponse(); + } + + if (!$circle) { + return new JSONResponse(); + } + + $circleMembers = $circle->getInheritedMembers(); + + foreach ($circleMembers as $circleMember) { + if ($circleMember->isLocal()) { + + $circleMemberUserId = $circleMember->getUserId(); + + $user = $this->userManager->get($circleMemberUserId); + + if ($user === null) { + throw new ServiceException('Could not find organizer'); + } + + $contacts[] = [ + 'commonName' => $circleMember->getDisplayName(), + 'calendarUserType' => 'INDIVIDUAL', + 'email' => $user->getEMailAddress(), + 'isUser' => true, + 'avatar' => $circleMemberUserId, + 'hasMultipleEMails' => false, + 'dropdownName' => $circleMember->getDisplayName(), + 'member' => 'mailto:circle+' . $circleId . '@' . $circleMember->getInstance(), + ]; + } + } + + return new JSONResponse($contacts); + } + + /** * Get a contact's photo based on their email-address * diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index f82e7c9df..afc14aba2 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -6,8 +6,10 @@ declare(strict_types=1); * * @author Georg Ehrke * @author Richard Steinmetz + * @author Jonas Heinrich * @copyright 2019 Georg Ehrke * @copyright Copyright (c) 2022 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ + * @copyright 2023 Jonas Heinrich * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE @@ -25,6 +27,7 @@ declare(strict_types=1); */ namespace OCA\Calendar\Controller; +use OC\App\CompareVersion; use OCA\Calendar\Service\Appointments\AppointmentConfigService; use OCP\App\IAppManager; use OCP\AppFramework\Controller; @@ -51,6 +54,9 @@ class ViewController extends Controller { /** @var IAppManager */ private $appManager; + /** @var CompareVersion */ + private $compareVersion; + /** @var string */ private $userId; @@ -62,6 +68,7 @@ class ViewController extends Controller { AppointmentConfigService $appointmentConfigService, IInitialState $initialStateService, IAppManager $appManager, + CompareVersion $compareVersion, ?string $userId, IAppData $appData) { parent::__construct($appName, $request); @@ -69,6 +76,7 @@ class ViewController extends Controller { $this->appointmentConfigService = $appointmentConfigService; $this->initialStateService = $initialStateService; $this->appManager = $appManager; + $this->compareVersion = $compareVersion; $this->userId = $userId; $this->appData = $appData; } @@ -117,6 +125,11 @@ class ViewController extends Controller { $talkApiVersion = version_compare($this->appManager->getAppVersion('spreed'), '12.0.0', '>=') ? 'v4' : 'v1'; $tasksEnabled = $this->appManager->isEnabledForUser('tasks'); + $circleVersion = $this->appManager->getAppVersion('circles'); + $isCirclesEnabled = $this->appManager->isEnabledForUser('circles') === true; + // if circles is not installed, we use 0.0.0 + $isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', '22'); + $this->initialStateService->provideInitialState('app_version', $appVersion); $this->initialStateService->provideInitialState('event_limit', $eventLimit); $this->initialStateService->provideInitialState('first_run', $firstRun); @@ -138,6 +151,7 @@ class ViewController extends Controller { $this->initialStateService->provideInitialState('disable_appointments', $disableAppointments); $this->initialStateService->provideInitialState('can_subscribe_link', $canSubscribeLink); $this->initialStateService->provideInitialState('show_resources', $showResources); + $this->initialStateService->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible); return new TemplateResponse($this->appName, 'main'); } diff --git a/lib/Service/ServiceException.php b/lib/Service/ServiceException.php new file mode 100644 index 000000000..2bafe90f4 --- /dev/null +++ b/lib/Service/ServiceException.php @@ -0,0 +1,8 @@ + + + + + @@ -48,6 +52,9 @@ + + + diff --git a/src/components/Editor/Invitees/InviteesList.vue b/src/components/Editor/Invitees/InviteesList.vue index a40f00bc8..13393e2da 100644 --- a/src/components/Editor/Invitees/InviteesList.vue +++ b/src/components/Editor/Invitees/InviteesList.vue @@ -27,6 +27,7 @@
{ - return attendee.attendeeProperty.userType === 'GROUP' + return this.invitees.filter(attendee => { + if (attendee.attendeeProperty.userType === 'GROUP') { + attendee.members = this.invitees.filter(invitee => { + return invitee.attendeeProperty.member + && invitee.attendeeProperty.member.includes(attendee.uri) + && attendee.attendeeProperty.userType === 'GROUP' + }) + return attendee.members.length > 0 + } + return false }) }, inviteesWithoutOrganizer() { @@ -146,36 +155,20 @@ export default { return this.invitees .filter(attendee => { // Filter attendees which are part of an invited group - let isMemberOfGroup = false - if (attendee.attendeeProperty.member) { - isMemberOfGroup = this.groups.some(function(group) { - return attendee.attendeeProperty.member.includes(group.uri) - && attendee.attendeeProperty.userType === 'INDIVIDUAL' - }) + if (this.groups.some(function(group) { + return attendee.attendeeProperty.member + && attendee.attendeeProperty.member.includes(group.uri) + && attendee.attendeeProperty.userType === 'INDIVIDUAL' + })) { + return false } - // Add attendee to group member list - if (isMemberOfGroup) { - this.groups.forEach(group => { - if (attendee.member.includes(group.uri)) { - if (!group.members) { - group.members = [] - } - group.members.push(attendee) - } - }) + // Filter empty groups + if (attendee.attendeeProperty.userType === 'GROUP') { + return attendee.members.length > 0 } - // Check if attendee is an empty group - let isEmptyGroup = attendee.attendeeProperty.userType === 'GROUP' - this.invitees.forEach(invitee => { - if (invitee.member && invitee.member.includes(attendee.uri)) { - isEmptyGroup = false - } - }) - return attendee.uri !== this.calendarObjectInstance.organizer.uri - && !isMemberOfGroup && !isEmptyGroup }) }, isOrganizer() { @@ -230,7 +223,7 @@ export default { }, }, methods: { - addAttendee({ commonName, email, calendarUserType, language, timezoneId }) { + addAttendee({ commonName, email, calendarUserType, language, timezoneId, member }) { this.$store.commit('addAttendee', { calendarObjectInstance: this.calendarObjectInstance, commonName, @@ -242,6 +235,7 @@ export default { language, timezoneId, organizer: this.$store.getters.getCurrentUserPrincipal, + member, }) }, removeAttendee(attendee) { diff --git a/src/components/Editor/Invitees/InviteesListSearch.vue b/src/components/Editor/Invitees/InviteesListSearch.vue index 8858229a5..ad9acfcb5 100644 --- a/src/components/Editor/Invitees/InviteesListSearch.vue +++ b/src/components/Editor/Invitees/InviteesListSearch.vue @@ -1,8 +1,10 @@