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 <anna@nextcloud.com>
Signed-off-by: Claus-Justus Heine <himself@claus-justus-heine.de>
This commit is contained in:
Claus-Justus Heine 2023-04-24 14:30:48 +02:00 committed by Christoph Wurst
parent 8da83332bd
commit b3af4c3507
No known key found for this signature in database
GPG Key ID: CC42AC2A7F0E56D8
8 changed files with 376 additions and 14 deletions

View File

@ -25,6 +25,7 @@ declare(strict_types=1);
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 @@ class ViewController extends Controller {
IRequest $request,
IConfig $config,
AppointmentConfigService $appointmentConfigService,
CategoriesService $categoriesService,
IInitialState $initialStateService,
IAppManager $appManager,
?string $userId,
@ -66,6 +71,7 @@ class ViewController extends Controller {
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 @@ class ViewController extends Controller {
$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');
}

View File

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* Calendar App
*
* @copyright 2023 Claus-Justus Heine <himself@claus-justus-heine.de>
*
* @author Claus-Justus Heine <himself@claus-justus-heine.de>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<int, Category>}
*/
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;
}
}

View File

@ -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
},
},
}
</script>

View File

@ -23,7 +23,7 @@
<template>
<span class="property-select-multiple-colored-tag">
<div class="property-select-multiple-colored-tag__color-indicator" :style="{ 'background-color': color}" />
<div v-if="!isGroupLabel" class="property-select-multiple-colored-tag__color-indicator" :style="{ 'background-color': color }" />
<span class="property-select-multiple-colored-tag__label">{{ label }}</span>
</span>
</template>
@ -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)

View File

@ -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
*

View File

@ -147,8 +147,9 @@
<PropertySelectMultiple :colored-options="true"
:is-read-only="isReadOnly"
:prop-model="rfcProps.categories"
:prop-model="categoryOptions"
:value="categories"
:custom-label-heading="t('calendar', 'Custom Categories')"
@add-single-value="addCategory"
@remove-single-value="removeCategory" />

View File

@ -28,6 +28,7 @@ namespace OCA\Calendar\Controller;
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 @@ class ViewControllerTest extends TestCase {
$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 @@ class ViewControllerTest extends TestCase {
$this->request,
$this->config,
$this->appointmentContfigService,
$this->categoriesService,
$this->initialStateService,
$this->appManager,
$this->userId,
@ -133,7 +139,18 @@ class ViewControllerTest extends TestCase {
->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 @@ class ViewControllerTest extends TestCase {
['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();

View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
/*
* @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @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 <http://www.gnu.org/licenses/>.
*/
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'));
}
}