From b3af4c35079827908b6957519ad60ef6773e5e8c Mon Sep 17 00:00:00 2001 From: Claus-Justus Heine Date: Mon, 24 Apr 2023 14:30:48 +0200 Subject: [PATCH] Augment the category menu by system tags and already used categories. This commit add all available "collaborative tags" and all already used categories into option groups of the tags-menu of the side-bar editor. This commit addresses and is a related to a couple of open issues: nextcloud/calendar#3735 Calendar Categories: Propose Categories already used - this should be fixed by this commit nextcloud/calendar#1644 Add own categories, delete default ones - this is partly fixed in the sense that collaboritive tags are now also proposed as calendar categories. - still default categories cannot be deleted - however, using option groups one at least has some sort of overview about the origin of the proposed category nextcloud/server#29950 Save VEVENT CATEGORIES as vcategory - this issue is totally "ignored" by this commit as the proposed solution there is not needed (the categories are already there in the oc_calendarobject_props table) - that would have to be discussed there: but my impression that the tables and classed mentioned there are obsolete and no longer used. Co-authored-by: Anna Signed-off-by: Claus-Justus Heine --- lib/Controller/ViewController.php | 7 + lib/Service/CategoriesService.php | 144 +++++++++++++++++ .../Properties/PropertySelectMultiple.vue | 47 ++++-- .../PropertySelectMultipleColoredOption.vue | 7 +- src/mixins/EditorMixin.js | 6 + src/views/EditSidebar.vue | 3 +- .../unit/Controller/ViewControllerTest.php | 30 +++- .../unit/Service/CategoriesServiceTest.php | 146 ++++++++++++++++++ 8 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 lib/Service/CategoriesService.php create mode 100644 tests/php/unit/Service/CategoriesServiceTest.php diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 616f6d45a9..5d018498ce 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -25,6 +25,7 @@ namespace OCA\Calendar\Controller; use OCA\Calendar\Service\Appointments\AppointmentConfigService; +use OCA\Calendar\Service\CategoriesService; use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\FileDisplayResponse; @@ -44,6 +45,9 @@ class ViewController extends Controller { /** @var AppointmentConfigService */ private $appointmentConfigService; + /** @var CategoriesService */ + private $categoriesService; + /** @var IInitialState */ private $initialStateService; @@ -59,6 +63,7 @@ public function __construct(string $appName, IRequest $request, IConfig $config, AppointmentConfigService $appointmentConfigService, + CategoriesService $categoriesService, IInitialState $initialStateService, IAppManager $appManager, ?string $userId, @@ -66,6 +71,7 @@ public function __construct(string $appName, parent::__construct($appName, $request); $this->config = $config; $this->appointmentConfigService = $appointmentConfigService; + $this->categoriesService = $categoriesService; $this->initialStateService = $initialStateService; $this->appManager = $appManager; $this->userId = $userId; @@ -135,6 +141,7 @@ public function index():TemplateResponse { $this->initialStateService->provideInitialState('appointmentConfigs', $this->appointmentConfigService->getAllAppointmentConfigurations($this->userId)); $this->initialStateService->provideInitialState('disable_appointments', $disableAppointments); $this->initialStateService->provideInitialState('can_subscribe_link', $canSubscribeLink); + $this->initialStateService->provideInitialState('categories', $this->categoriesService->getCategories($this->userId)); return new TemplateResponse($this->appName, 'main'); } diff --git a/lib/Service/CategoriesService.php b/lib/Service/CategoriesService.php new file mode 100644 index 0000000000..0c8e744783 --- /dev/null +++ b/lib/Service/CategoriesService.php @@ -0,0 +1,144 @@ + + * + * @author Claus-Justus Heine + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library 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 library. If not, see . + * + */ + +namespace OCA\Calendar\Service; + +use OCP\Calendar\ICalendarQuery; +use OCP\Calendar\IManager as ICalendarManager; +use OCP\IL10N; +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; + +/** + * @psalm-type Category = array{label: string, value: string} + * @psalm-type CategoryGroup = array{group: string, options: array} + */ +class CategoriesService { + /** @var ICalendarManager */ + private $calendarManager; + + /** @var ISystemTagManager */ + private $systemTagManager; + + /** @var IL10N */ + private $l; + + public function __construct(ICalendarManager $calendarManager, + ISystemTagManager $systemTagManager, + IL10N $l10n) { + $this->calendarManager = $calendarManager; + $this->systemTagManager = $systemTagManager; + $this->l = $l10n; + } + + /** + * This is a simplistic brute-force extraction of all already used + * categories from all events accessible to the given user. + * + * @return array + */ + private function getUsedCategories(string $userId): array { + $categories = []; + $principalUri = 'principals/users/' . $userId; + $query = $this->calendarManager->newQuery($principalUri); + $query->addSearchProperty(ICalendarQuery::SEARCH_PROPERTY_CATEGORIES); + $calendarObjects = $this->calendarManager->searchForPrincipal($query); + foreach ($calendarObjects as $objectInfo) { + foreach ($objectInfo['objects'] as $calendarObject) { + if (isset($calendarObject['CATEGORIES'])) { + $categories[] = explode(',', $calendarObject['CATEGORIES'][0][0]); + } + } + } + + // Avoid injecting "broken" categories into the UI (avoid empty + // categories and categories surrounded by spaces) + $categories = array_filter(array_map(fn ($label) => trim($label), array_unique(array_merge(...$categories)))); + + return $categories; + } + + /** + * Return a grouped array with all previously used categories, all system + * tags and all categories found in the iCalendar RFC. + * + * @return CategoryGroup[] + */ + public function getCategories(string $userId): array { + $systemTags = $this->systemTagManager->getAllTags(true); + + $systemTagCategoryLabels = []; + /** @var ISystemTag $systemTag */ + foreach ($systemTags as $systemTag) { + if (!$systemTag->isUserAssignable() || !$systemTag->isUserVisible()) { + continue; + } + $systemTagCategoryLabels[] = $systemTag->getName(); + } + sort($systemTagCategoryLabels); + $systemTagCategoryLabels = array_values(array_filter(array_unique($systemTagCategoryLabels))); + + $rfcCategoryLabels = [ + $this->l->t('Anniversary'), + $this->l->t('Appointment'), + $this->l->t('Business'), + $this->l->t('Education'), + $this->l->t('Holiday'), + $this->l->t('Meeting'), + $this->l->t('Miscellaneous'), + $this->l->t('Non-working hours'), + $this->l->t('Not in office'), + $this->l->t('Personal'), + $this->l->t('Phone call'), + $this->l->t('Sick day'), + $this->l->t('Special occasion'), + $this->l->t('Travel'), + $this->l->t('Vacation'), + ]; + sort($rfcCategoryLabels); + $rfcCategoryLabels = array_values(array_filter(array_unique($rfcCategoryLabels))); + + $standardCategories = array_merge($systemTagCategoryLabels, $rfcCategoryLabels); + $customCategoryLabels = array_values(array_filter($this->getUsedCategories($userId), fn ($label) => !in_array($label, $standardCategories))); + + $categories = [ + [ + 'group' => $this->l->t('Custom Categories'), + 'options' => array_map(fn (string $label) => [ 'label' => $label, 'value' => $label ], $customCategoryLabels), + ], + [ + 'group' => $this->l->t('Collaborative Tags'), + 'options' => array_map(fn (string $label) => [ 'label' => $label, 'value' => $label ], $systemTagCategoryLabels), + ], + [ + 'group' => $this->l->t('Standard Categories'), + 'options' => array_map(fn (string $label) => [ 'label' => $label, 'value' => $label ], $rfcCategoryLabels), + ], + ]; + + return $categories; + } +} diff --git a/src/components/Editor/Properties/PropertySelectMultiple.vue b/src/components/Editor/Properties/PropertySelectMultiple.vue index 5aff59f233..adeccf664d 100644 --- a/src/components/Editor/Properties/PropertySelectMultiple.vue +++ b/src/components/Editor/Properties/PropertySelectMultiple.vue @@ -42,6 +42,9 @@ :multiple="true" :taggable="true" track-by="label" + group-values="options" + group-label="group" + :group-select="false" label="label" @select="selectValue" @tag="tag" @@ -99,6 +102,10 @@ export default { type: Boolean, default: false, }, + customLabelHeading: { + type: String, + default: 'Custom Categories', + }, }, data() { return { @@ -111,45 +118,56 @@ export default { }, options() { const options = this.propModel.options.slice() + let customOptions = options.find((optionGroup) => optionGroup.group === this.customLabelHeading) + if (!customOptions) { + customOptions = { + group: this.customLabelHeading, + options: [], + } + options.unshift(customOptions) + } for (const category of (this.selectionData ?? [])) { - if (options.find(option => option.value === category.value)) { + if (this.findOption(category, options)) { continue } // Add pseudo options for unknown values - options.push({ + customOptions.options.push({ value: category.value, label: category.label, }) } for (const category of this.value) { - if (!options.find(option => option.value === category)) { - options.push({ value: category, label: category }) + const categoryOption = { value: category, label: category } + if (!this.findOption(categoryOption, options)) { + customOptions.options.push(categoryOption) } } if (this.customLabelBuffer) { for (const category of this.customLabelBuffer) { - if (!options.find(option => option.value === category.value)) { - options.push(category) + if (!this.findOption(category, options)) { + customOptions.options.push(category) } } } - return options - .sort((a, b) => { + for (const optionGroup of options) { + optionGroup.options = optionGroup.options.sort((a, b) => { return a.label.localeCompare( b.label, getLocale().replace('_', '-'), { sensitivity: 'base' }, ) }) + } + return options }, }, created() { for (const category of this.value) { - const option = this.options.find(option => option.value === category) + const option = this.findOption({ value: category }, this.options) if (option) { this.selectionData.push(option) } @@ -172,7 +190,7 @@ export default { // store removed custom options to keep it in the option list const options = this.propModel.options.slice() - if (!options.find(option => option.value === value.value)) { + if (!this.findOption(value, options)) { if (!this.customLabelBuffer) { this.customLabelBuffer = [] } @@ -187,6 +205,15 @@ export default { this.selectionData.push({ value, label: value }) this.$emit('add-single-value', value) }, + findOption(value, availableOptions) { + for (const optionGroup of availableOptions) { + const option = optionGroup.options.find(option => option.value === value.value) + if (option) { + return option + } + } + return undefined + }, }, } diff --git a/src/components/Editor/Properties/PropertySelectMultipleColoredOption.vue b/src/components/Editor/Properties/PropertySelectMultipleColoredOption.vue index 546cdce327..825d0e3ebf 100644 --- a/src/components/Editor/Properties/PropertySelectMultipleColoredOption.vue +++ b/src/components/Editor/Properties/PropertySelectMultipleColoredOption.vue @@ -23,7 +23,7 @@ @@ -41,6 +41,9 @@ export default { }, }, computed: { + isGroupLabel() { + return this.option.$isLabel && this.option.$groupLabel + }, label() { const option = this.option logger.debug('Option render', { option }) @@ -48,7 +51,7 @@ export default { return this.option } - return this.option.label + return this.option.$groupLabel ? this.option.$groupLabel : this.option.label }, colorObject() { return uidToColor(this.label) diff --git a/src/mixins/EditorMixin.js b/src/mixins/EditorMixin.js index 7158ba4805..c17bea2d2a 100644 --- a/src/mixins/EditorMixin.js +++ b/src/mixins/EditorMixin.js @@ -33,6 +33,7 @@ import { } from 'vuex' import { translate as t } from '@nextcloud/l10n' import { removeMailtoPrefix } from '../utils/attendee.js' +import { loadState } from '@nextcloud/initial-state' /** * This is a mixin for the editor. It contains common Vue stuff, that is @@ -314,6 +315,11 @@ export default { rfcProps() { return getRFCProperties() }, + categoryOptions() { + const categories = { ...this.rfcProps.categories } + categories.options = loadState('calendar', 'categories') + return categories + }, /** * Returns whether or not this event can be downloaded from the server * diff --git a/src/views/EditSidebar.vue b/src/views/EditSidebar.vue index 187a2f188c..75f3bd8735 100644 --- a/src/views/EditSidebar.vue +++ b/src/views/EditSidebar.vue @@ -147,8 +147,9 @@ diff --git a/tests/php/unit/Controller/ViewControllerTest.php b/tests/php/unit/Controller/ViewControllerTest.php index 9d5aa277cb..222a8efa0d 100755 --- a/tests/php/unit/Controller/ViewControllerTest.php +++ b/tests/php/unit/Controller/ViewControllerTest.php @@ -28,6 +28,7 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Calendar\Db\AppointmentConfig; use OCA\Calendar\Service\Appointments\AppointmentConfigService; +use OCA\Calendar\Service\CategoriesService; use OCP\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; @@ -52,6 +53,9 @@ class ViewControllerTest extends TestCase { /** @var AppointmentConfigService|MockObject */ private $appointmentContfigService; + /** @var CategoriesService|MockObject */ + private $categoriesService; + /** @var IInitialState|MockObject */ private $initialStateService; @@ -70,6 +74,7 @@ protected function setUp(): void { $this->appManager = $this->createMock(IAppManager::class); $this->config = $this->createMock(IConfig::class); $this->appointmentContfigService = $this->createMock(AppointmentConfigService::class); + $this->categoriesService = $this->createMock(CategoriesService::class); $this->initialStateService = $this->createMock(IInitialState::class); $this->userId = 'user123'; $this->appData = $this->createMock(IAppData::class); @@ -79,6 +84,7 @@ protected function setUp(): void { $this->request, $this->config, $this->appointmentContfigService, + $this->categoriesService, $this->initialStateService, $this->appManager, $this->userId, @@ -133,7 +139,18 @@ public function testIndex(): void { ->method('getAllAppointmentConfigurations') ->with($this->userId) ->willReturn([new AppointmentConfig()]); - + $this->categoriesService->expects(self::once()) + ->method('getCategories') + ->with('user123') + ->willReturn([ + [ + 'group' => 'Test', + 'options' => [ + 'label' => 'hawaii', + 'value' => 'pizza', + ], + ], + ]); $this->initialStateService ->method('provideInitialState') ->withConsecutive( @@ -155,6 +172,17 @@ public function testIndex(): void { ['hide_event_export', true], ['force_event_alarm_type', null], ['appointmentConfigs', [new AppointmentConfig()]], + ['disable_appointments', false], + ['can_subscribe_link', false], + ['categories', [ + [ + 'group' => 'Test', + 'options' => [ + 'label' => 'hawaii', + 'value' => 'pizza', + ], + ], + ]], ); $response = $this->controller->index(); diff --git a/tests/php/unit/Service/CategoriesServiceTest.php b/tests/php/unit/Service/CategoriesServiceTest.php new file mode 100644 index 0000000000..65f015a93b --- /dev/null +++ b/tests/php/unit/Service/CategoriesServiceTest.php @@ -0,0 +1,146 @@ + + * + * @author 2023 Christoph Wurst + * + * @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 . + */ + +namespace OCA\Calendar\Tests\Unit\Service; + +use ChristophWurst\Nextcloud\Testing\ServiceMockObject; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Calendar\Service\CategoriesService; +use OCP\SystemTag\ISystemTag; +use function array_column; + +class CategoriesServiceTest extends TestCase { + private ServiceMockObject $serviceMock; + private CategoriesService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->serviceMock = $this->createServiceMock(CategoriesService::class); + $this->service = $this->serviceMock->getService(); + + $this->serviceMock->getParameter('l10n') + ->method('t') + ->willReturnArgument(0); + } + + public function testGetCategoriesDefaultsOnly(): void { + $categories = $this->service->getCategories('user123'); + + self::assertCount(3, $categories); + self::assertEquals( + [ + 'Custom Categories', + 'Collaborative Tags', + 'Standard Categories', + ], + array_column($categories, 'group') + ); + self::assertCount(0, $categories[0]['options']); + self::assertCount(0, $categories[1]['options']); + self::assertCount(15, $categories[2]['options']); + } + + public function testGetUsedCategories(): void { + $this->serviceMock->getParameter('calendarManager') + ->expects(self::once()) + ->method('searchForPrincipal') + ->willReturn([ + [ + 'objects' => [], + ], + [ + 'objects' => [ + [ + 'CATEGORIES' => [ + [ + '', + [], + ] + ], + ], + ], + ], + [ + 'objects' => [ + [ + 'CATEGORIES' => [ + [ + 'pizza,party', + [], + ] + ], + ], + ], + ], + [ + 'objects' => [ + [ + 'CATEGORIES' => [ + [ + 'pizza,hawaii', + [], + ] + ], + ], + ], + ], + ]); + + $categories = $this->service->getCategories('user123'); + + self::assertArrayHasKey(0, $categories); + self::assertCount(3, $categories[0]['options']); + self::assertEquals(['pizza', 'party', 'hawaii'], array_column($categories[0]['options'], 'label')); + } + + public function testGetSystemTagsAsCategories(): void { + $tag1 = $this->createMock(ISystemTag::class); + $tag1->method('isUserAssignable')->willReturn(false); + $tag1->method('isUserVisible')->willReturn(true); + $tag2 = $this->createMock(ISystemTag::class); + $tag2->method('isUserAssignable')->willReturn(false); + $tag2->method('isUserVisible')->willReturn(false); + $tag3 = $this->createMock(ISystemTag::class); + $tag3->method('isUserAssignable')->willReturn(true); + $tag3->method('isUserVisible')->willReturn(true); + $tag3->method('getName')->willReturn('fun'); + $this->serviceMock->getParameter('systemTagManager') + ->expects(self::once()) + ->method('getAllTags') + ->with(true) + ->willReturn([ + $tag1, + $tag2, + $tag3, + ]); + + $categories = $this->service->getCategories('user123'); + + self::assertArrayHasKey(1, $categories); + self::assertCount(1, $categories[1]['options']); + self::assertEquals(['fun'], array_column($categories[1]['options'], 'label')); + } +}