From 93f13e517f0b4f81e34e47fcf9aa3ab6e438d9b6 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. Determining the set of already used categories is a little bit ugly: it used the oc_calendarobject_props table which might be considered "internal". However, this is the Nextcloud calendar app which only talks to the Nextcloud calendar server. So using this "internal ingredient" might be acceptable. 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. Signed-off-by: Claus-Justus Heine --- lib/Controller/ViewController.php | 7 + lib/Service/CategoriesService.php | 166 ++++++++++++++++++ .../Properties/PropertySelectMultiple.vue | 47 +++-- .../PropertySelectMultipleColoredOption.vue | 7 +- src/mixins/EditorMixin.js | 6 + src/views/EditSidebar.vue | 3 +- .../unit/Controller/ViewControllerTest.php | 6 + 7 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 lib/Service/CategoriesService.php diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index 616f6d45a9..de30044dc2 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()); return new TemplateResponse($this->appName, 'main'); } diff --git a/lib/Service/CategoriesService.php b/lib/Service/CategoriesService.php new file mode 100644 index 0000000000..01ab4a1043 --- /dev/null +++ b/lib/Service/CategoriesService.php @@ -0,0 +1,166 @@ + + * + * @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\ICalendar; +use OCP\Calendar\IManager as ICalendarManager; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\SystemTag\ISystemTag; +use OCP\SystemTag\ISystemTagManager; +use Psr\Log\LoggerInterface; + +class CategoriesService { + /** @var null|string */ + private $userId; + + /** @var ICalendarManager */ + private $calendarManager; + + /** @var ISystemTagManager */ + private $systemTagManager; + + /** @var IDBConnection */ + private $db; + + /** @var LoggerInterface */ + private $logger; + + /** @var IL10N */ + private $l; + + private const CALENDAR_OBJECT_PROPERTIES_TABLE = 'calendarobjects_props'; + + public function __construct(?string $userId, + ICalendarManager $calendarManager, + ISystemTagManager $systemTagManager, + IDBConnection $db, + LoggerInterface $logger, + IL10N $l10n) { + $this->userId = $userId; + $this->calendarManager = $calendarManager; + $this->systemTagManager = $systemTagManager; + $this->db = $db; + $this->logger = $logger; + $this->l = $l10n; + } + + /** + * This is a simplistic brute-force extraction of all already used + * categories from all events accessible to the currently logged in user. + * + * @return array + */ + private function getUsedCategories(): array { + if (empty($this->userId)) { + return []; + } + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $this->userId); + $count = count($calendars); + if ($count === 0) { + return []; + } + + $categories = []; + $calendarObjects = $this->calendarManager->search(''); + foreach ($calendarObjects as $objectInfo) { + foreach ($objectInfo['objects'] as $calendarObject) { + if (isset($calendarObject['CATEGORIES'])) { + $eventCategories = explode(',', $calendarObject['CATEGORIES'][0][0]); + $categories = array_merge( + $categories, + array_fill_keys($eventCategories, true) + ); + } + } + } + $categories = array_filter(array_map(fn ($label) => trim($label), array_keys($categories))); + + return $categories; + } + + /** + * Return a grouped array with all previously used categories, all system + * tags and all categories found the the iCalendar RFC. + * + * @return array + */ + public function getCategories(): 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(), 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 a732c381d2..a2dcbc9fcd 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 d297237727..d3b9a8833a 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..d3a9eae90e 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,