diff --git a/lib/Controller/ViewController.php b/lib/Controller/ViewController.php index f82e7c9df2..b104bc7776 100644 --- a/lib/Controller/ViewController.php +++ b/lib/Controller/ViewController.php @@ -26,6 +26,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; @@ -45,6 +46,9 @@ class ViewController extends Controller { /** @var AppointmentConfigService */ private $appointmentConfigService; + /** @var CategoriesService */ + private $categoriesService; + /** @var IInitialState */ private $initialStateService; @@ -60,6 +64,7 @@ public function __construct(string $appName, IRequest $request, IConfig $config, AppointmentConfigService $appointmentConfigService, + CategoriesService $categoriesService, IInitialState $initialStateService, IAppManager $appManager, ?string $userId, @@ -67,6 +72,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; @@ -138,6 +144,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('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..7a38ac59d3 --- /dev/null +++ b/lib/Service/CategoriesService.php @@ -0,0 +1,172 @@ + + * + * @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\AppFramework\Http; +use OCP\IL10N; +use Psr\Log\LoggerInterface; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTag; +use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Calendar\IManager as ICalendarManager; +use OCP\Calendar\ICalendarQuery; +use OCP\Calendar\ICalendar; + +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 []; + } + $principalUri = 'principals/users/' . $this->userId; + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $this->userId); + $count = count($calendars); + if ($count === 0) { + return []; + } + + $categories = []; + $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 the the iCalendar RFC. + * + * @return array + */ + public function getCategories(): array { + $systemTags = $this->systemTagManager->getAllTags(visibilityFilter: 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 5bc93fd2d8..21f4523cb0 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 f229d26687..6f14441a0c 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 e17f6dd53d..b27218657e 100644 --- a/src/views/EditSidebar.vue +++ b/src/views/EditSidebar.vue @@ -150,8 +150,9 @@