diff --git a/appinfo/routes.php b/appinfo/routes.php index 533b426e4c..26ff1deb7d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -5,9 +5,12 @@ * 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 @@ ['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 34fce6f51d..3ed07dc964 100644 --- a/lib/Controller/ContactController.php +++ b/lib/Controller/ContactController.php @@ -7,10 +7,12 @@ * @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,15 @@ */ namespace OCA\Calendar\Controller; +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 +49,12 @@ class ContactController extends Controller { /** @var IManager */ private $contactsManager; + /** @var IAppManager */ + private $appManager; + + /** @var IUserManager */ + private $userManager; + /** * ContactController constructor. * @@ -52,9 +64,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 +189,66 @@ public function searchAttendee(string $search):JSONResponse { 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 null; + } catch (CircleNotFoundException $ex) { + return null; + } + + if (!$circle) { + return null; + } + + $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 f82e7c9df2..4be7534da7 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -6,8 +6,10 @@ * * @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 @@ */ 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 @@ public function __construct(string $appName, AppointmentConfigService $appointmentConfigService, IInitialState $initialStateService, IAppManager $appManager, + CompareVersion $compareVersion, ?string $userId, IAppData $appData) { parent::__construct($appName, $request); @@ -69,6 +76,7 @@ public function __construct(string $appName, $this->appointmentConfigService = $appointmentConfigService; $this->initialStateService = $initialStateService; $this->appManager = $appManager; + $this->compareVersion = $compareVersion; $this->userId = $userId; $this->appData = $appData; } @@ -117,6 +125,11 @@ public function index():TemplateResponse { $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 ?? '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 @@ public function index():TemplateResponse { $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/psalm.xml b/psalm.xml index 46aa449516..01b35c48d9 100644 --- a/psalm.xml +++ b/psalm.xml @@ -27,6 +27,8 @@ + + @@ -47,7 +49,9 @@ - + + + diff --git a/src/components/Editor/Invitees/InviteesList.vue b/src/components/Editor/Invitees/InviteesList.vue index a40f00bc81..6003bd0a86 100644 --- a/src/components/Editor/Invitees/InviteesList.vue +++ b/src/components/Editor/Invitees/InviteesList.vue @@ -27,6 +27,7 @@
+ - @copyright Copyright (c) 2023 Jonas Heinrich - - @author Georg Ehrke - @author Richard Steinmetz + - @author Jonas Heinrich - - @license AGPL-3.0-or-later - @@ -43,7 +45,12 @@ :key="option.uid" :user="option.avatar" :display-name="option.dropdownName" /> - + + + @@ -52,9 +59,12 @@
{{ option.dropdownName }}
-
+
{{ option.email }}
+
+ {{ option.subtitle }} +
@@ -67,33 +77,46 @@ import { NcMultiselect as Multiselect, } from '@nextcloud/vue' import { principalPropertySearchByDisplaynameOrEmail } from '../../../services/caldavService.js' +import isCirclesEnabled from '../../../services/isCirclesEnabled.js' +import { + circleSearchByName, + circleGetMembers, +} from '../../../services/circleService.js' import HttpClient from '@nextcloud/axios' import debounce from 'debounce' import { linkTo } from '@nextcloud/router' import { randomId } from '../../../utils/randomId.js' +import GoogleCirclesCommunitiesIcon from 'vue-material-design-icons/GoogleCirclesCommunities.vue' +import { showInfo } from '@nextcloud/dialogs' export default { name: 'InviteesListSearch', components: { Avatar, Multiselect, + GoogleCirclesCommunitiesIcon, }, props: { alreadyInvitedEmails: { type: Array, required: true, }, + organizer: { + type: Object, + required: false, + }, }, data() { return { isLoading: false, inputGiven: false, matches: [], + isCirclesEnabled, } }, computed: { placeholder() { - return this.$t('calendar', 'Search for emails, users or contacts') + return this.$t('calendar', 'Search for emails, users, contacts or groups') }, noResult() { return this.$t('calendar', 'No match found') @@ -109,10 +132,16 @@ export default { this.findAttendeesFromContactsAPI(query), this.findAttendeesFromDAV(query), ] + if (isCirclesEnabled) { + promises.push(this.findAttendeesFromCircles(query)) + } - const [contactsResults, davResults] = await Promise.all(promises) + const [contactsResults, davResults, circleResults] = await Promise.all(promises) matches.push(...contactsResults) matches.push(...davResults) + if (isCirclesEnabled) { + matches.push(...circleResults) + } // Source of the Regex: https://stackoverflow.com/a/46181 // eslint-disable-next-line @@ -149,8 +178,27 @@ export default { this.matches = matches }, 500), addAttendee(selectedValue) { + + if (selectedValue.type === 'circle') { + showInfo(this.$t('calendar', 'Note that members of circles get invited but are not synced yet.')) + this.resolveCircleMembers(selectedValue.id, selectedValue.email) + } this.$emit('add-attendee', selectedValue) }, + async resolveCircleMembers(circleId, groupId) { + let results + try { + results = await circleGetMembers(circleId) + } catch (error) { + console.debug(error) + return [] + } + results.data.forEach((member) => { + if (!this.organizer || member.email !== this.organizer.uri) { + this.$emit('add-attendee', member) + } + }) + }, async findAttendeesFromContactsAPI(query) { let response @@ -239,6 +287,30 @@ export default { } }) }, + async findAttendeesFromCircles(query) { + let results + try { + results = await circleSearchByName(query) + } catch (error) { + console.debug(error) + return [] + } + + return results.filter((circle) => { + return true + }).map((circle) => { + return { + commonName: circle.displayname, + calendarUserType: 'GROUP', + email: 'circle+' + circle.id + '@' + circle.instance, + isUser: false, + dropdownName: circle.displayname, + type: 'circle', + id: circle.id, + subtitle: this.$n('calendar', '%n member', '%n members', circle.population), + } + }) + }, }, } diff --git a/src/services/circleService.js b/src/services/circleService.js new file mode 100644 index 0000000000..59b78ecac6 --- /dev/null +++ b/src/services/circleService.js @@ -0,0 +1,100 @@ +/** + * @copyright Copyright (c) 2023 Jonas Heinrich + * + * @author Jonas Heinrich + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import HttpClient from '@nextcloud/axios' +import { + generateOcsUrl, + linkTo, +} from '@nextcloud/router' + +/** + * Finds circles by displayname + * + * @param {string} query The search-term + * @return {Promise} + */ +const circleSearchByName = async (query) => { + let results + try { + results = await HttpClient.get(generateOcsUrl('apps/files_sharing/api/v1/') + 'sharees', { + params: { + format: 'json', + search: query, + perPage: 200, + itemType: 'pringroucipals', + }, + }) + } catch (error) { + return [] + } + + if (results.data.ocs.meta.status === 'failure') { + return [] + } + + let circles = [] + if (Array.isArray(results.data.ocs.data.circles)) { + circles = circles.concat(results.data.ocs.data.circles) + } + if (Array.isArray(results.data.ocs.data.exact.circles)) { + circles = circles.concat(results.data.ocs.data.exact.circles) + } + + if (circles.length === 0) { + return [] + } + + return circles.filter((circle) => { + return true + }).map(circle => ({ + displayname: circle.label, + population: circle.value.circle.population, + id: circle.value.circle.id, + instance: circle.value.circle.owner.instance, + })) +} + +/** + * Get members of circle by id + * + * @param {string} circleId The circle id to query + * @return {Promise} + */ +const circleGetMembers = async (circleId) => { + let results + try { + results = await HttpClient.get(linkTo('calendar', 'index.php') + '/v1/circles/getmembers', { + params: { + format: 'json', + circleId, + }, + }) + } catch (error) { + console.debug(error) + return [] + } + return results +} + +export { + circleSearchByName, + circleGetMembers, +} diff --git a/src/services/isCirclesEnabled.js b/src/services/isCirclesEnabled.js new file mode 100644 index 0000000000..09425c4d29 --- /dev/null +++ b/src/services/isCirclesEnabled.js @@ -0,0 +1,28 @@ +/** + * @copyright Copyright (c) 2021 John Molakvoæ + * @copyright Copyright (c) 2023 Jonas Heinrich + * + * @author John Molakvoæ + * @author Jonas Heinrich + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { loadState } from '@nextcloud/initial-state' + +const isCirclesEnabled = loadState('calendar', 'isCirclesEnabled', false) +export default isCirclesEnabled diff --git a/src/store/calendarObjectInstance.js b/src/store/calendarObjectInstance.js index 493bc8d996..043100321f 100644 --- a/src/store/calendarObjectInstance.js +++ b/src/store/calendarObjectInstance.js @@ -431,7 +431,7 @@ const mutations = { * @param {string=} data.timezoneId Preferred timezone of the attendee * @param {object=} data.organizer Principal of the organizer to be set if not present */ - addAttendee(state, { calendarObjectInstance, commonName, uri, calendarUserType = null, participationStatus = null, role = null, rsvp = null, language = null, timezoneId = null, organizer = null }) { + addAttendee(state, { calendarObjectInstance, commonName, uri, calendarUserType = null, participationStatus = null, role = null, rsvp = null, language = null, timezoneId = null, organizer = null, member = null }) { const attendee = AttendeeProperty.fromNameAndEMail(commonName, uri) if (calendarUserType !== null) { @@ -452,6 +452,9 @@ const mutations = { if (timezoneId !== null) { attendee.updateParameterIfExist('TZID', timezoneId) } + if (member !== null) { + attendee.updateParameterIfExist('MEMBER', member) + } // TODO - use real addAttendeeFrom method calendarObjectInstance.eventComponent.addProperty(attendee) diff --git a/tests/php/unit/Controller/ContactControllerTest.php b/tests/php/unit/Controller/ContactControllerTest.php index c74c733087..98f4d1bbc9 100644 --- a/tests/php/unit/Controller/ContactControllerTest.php +++ b/tests/php/unit/Controller/ContactControllerTest.php @@ -24,9 +24,11 @@ namespace OCA\Calendar\Controller; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCP\App\IAppManager; use OCP\AppFramework\Http\JSONResponse; use OCP\Contacts\IManager; use OCP\IRequest; +use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; class ContactControllerTest extends TestCase { @@ -39,6 +41,12 @@ class ContactControllerTest extends TestCase { /** @var IManager|MockObject */ protected $manager; + /** @var IAppManager|MockObject */ + private $appManager; + + /** @var IUserManager|MockObject */ + private $userManager; + /** @var ContactController */ protected $controller; @@ -48,8 +56,10 @@ protected function setUp():void { $this->appName = 'calendar'; $this->request = $this->createMock(IRequest::class); $this->manager = $this->createMock(IManager::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->userManager = $this->createMock(IUserManager::class); $this->controller = new ContactController($this->appName, - $this->request, $this->manager); + $this->request, $this->manager, $this->appManager, $this->userManager); } public function testSearchLocationDisabled():void { diff --git a/tests/php/unit/Controller/ViewControllerTest.php b/tests/php/unit/Controller/ViewControllerTest.php index 1742d7bdb3..44db1a26b0 100755 --- a/tests/php/unit/Controller/ViewControllerTest.php +++ b/tests/php/unit/Controller/ViewControllerTest.php @@ -27,6 +27,7 @@ namespace OCA\Calendar\Controller; use ChristophWurst\Nextcloud\Testing\TestCase; +use OC\App\CompareVersion; use OCA\Calendar\Db\AppointmentConfig; use OCA\Calendar\Service\Appointments\AppointmentConfigService; use OCP\App\IAppManager; @@ -65,6 +66,9 @@ class ViewControllerTest extends TestCase { /** @var IAppData|MockObject */ private $appData; + /** @var CompareVersion|MockObject*/ + private $compareVersion; + protected function setUp(): void { $this->appName = 'calendar'; $this->request = $this->createMock(IRequest::class); @@ -72,6 +76,7 @@ protected function setUp(): void { $this->config = $this->createMock(IConfig::class); $this->appointmentContfigService = $this->createMock(AppointmentConfigService::class); $this->initialStateService = $this->createMock(IInitialState::class); + $this->compareVersion = $this->createMock(CompareVersion::class); $this->userId = 'user123'; $this->appData = $this->createMock(IAppData::class); @@ -82,6 +87,7 @@ protected function setUp(): void { $this->appointmentContfigService, $this->initialStateService, $this->appManager, + $this->compareVersion, $this->userId, $this->appData, ); @@ -122,13 +128,14 @@ public function testIndex(): void { ['user123', 'calendar', 'defaultReminder', 'defaultDefaultReminder', '00:10:00'], ['user123', 'calendar', 'showTasks', 'defaultShowTasks', '00:15:00'], ]); - $this->appManager->expects(self::exactly(2)) + $this->appManager->expects(self::exactly(3)) ->method('isEnabledForUser') ->willReturnMap([ ['spreed', null, true], - ['tasks', null, true] + ['tasks', null, true], + ['circles', null, false], ]); - $this->appManager->expects(self::once()) + $this->appManager->expects(self::exactly(2)) ->method('getAppVersion') ->willReturnMap([ ['spreed', true, '12.0.0'], @@ -137,7 +144,7 @@ public function testIndex(): void { ->method('getAllAppointmentConfigurations') ->with($this->userId) ->willReturn([new AppointmentConfig()]); - $this->initialStateService->expects(self::exactly(21)) + $this->initialStateService->expects(self::exactly(22)) ->method('provideInitialState') ->withConsecutive( ['app_version', '1.0.0'], @@ -161,6 +168,7 @@ public function testIndex(): void { ['disable_appointments', false], ['can_subscribe_link', false], ['show_resources', true], + ['isCirclesEnabled', false], ); $response = $this->controller->index(); @@ -212,22 +220,24 @@ public function testIndexViewFix(string $savedView, string $expectedView): void ['user123', 'calendar', 'defaultReminder', 'defaultDefaultReminder', '00:10:00'], ['user123', 'calendar', 'showTasks', 'defaultShowTasks', '00:15:00'], ]); - $this->appManager->expects(self::exactly(2)) + $this->appManager->expects(self::exactly(3)) ->method('isEnabledForUser') ->willReturnMap([ ['spreed', null, false], - ['tasks', null, false] + ['tasks', null, false], + ['circles', null, false], ]); - $this->appManager->expects(self::once()) + $this->appManager->expects(self::exactly(2)) ->method('getAppVersion') ->willReturnMap([ ['spreed', true, '11.3.0'], + ['circles', true, '22.0.0'], ]); $this->appointmentContfigService->expects(self::once()) ->method('getAllAppointmentConfigurations') ->with($this->userId) ->willReturn([new AppointmentConfig()]); - $this->initialStateService->expects(self::exactly(21)) + $this->initialStateService->expects(self::exactly(22)) ->method('provideInitialState') ->withConsecutive( ['app_version', '1.0.0'], @@ -251,6 +261,7 @@ public function testIndexViewFix(string $savedView, string $expectedView): void ['disable_appointments', false], ['can_subscribe_link', false], ['show_resources', true], + ['isCirclesEnabled', false], ); $response = $this->controller->index();