Skip to content

Commit

Permalink
Augment the category menu by all system tags and all already used cat…
Browse files Browse the repository at this point in the history
…egories.

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#3735 Calendar Categories: Propose Categories already used

- this should be fixed by this commit

nextcloud#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.
  • Loading branch information
rotdrop committed Apr 24, 2023
1 parent 4e71d61 commit 8b37bad
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 13 deletions.
7 changes: 7 additions & 0 deletions lib/Controller/ViewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,6 +45,9 @@ class ViewController extends Controller {
/** @var AppointmentConfigService */
private $appointmentConfigService;

/** @var CategoriesService */
private $categoriesService;

/** @var IInitialState */
private $initialStateService;

Expand All @@ -59,13 +63,15 @@ public function __construct(string $appName,
IRequest $request,
IConfig $config,
AppointmentConfigService $appointmentConfigService,
CategoriesService $categoriesService,
IInitialState $initialStateService,
IAppManager $appManager,
?string $userId,
IAppData $appData) {
parent::__construct($appName, $request);
$this->config = $config;
$this->appointmentConfigService = $appointmentConfigService;
$this->categoriesService = $categoriesService;
$this->initialStateService = $initialStateService;
$this->appManager = $appManager;
$this->userId = $userId;
Expand Down Expand Up @@ -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');
}
Expand Down
153 changes: 153 additions & 0 deletions lib/Service/CategoriesService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?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\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\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;
}

private function getUsedCategories(): array
{
if (empty($this->userId)) {
return [];
}
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $this->userId);
$count = count($calendars);
if ($count === 0) {
return [];
}
$calendarIds = array_map(fn(ICalendar $calendar) => $calendar->getKey(), $calendars);
$qb = $this->db->getQueryBuilder();
$qb->selectDistinct('value')
->from(self::CALENDAR_OBJECT_PROPERTIES_TABLE)
->where($qb->expr()->in('calendarid', $qb->createNamedParameter($calendarIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->eq('name', $qb->createNamedParameter('CATEGORIES')));
$result = $qb->executeQuery();
$rawCategories = $result->fetchAll();
$result->closeCursor();

$categories = array_values(array_filter(array_unique(array_merge(...array_map(fn($result) => explode(',', $result['value'] ?? ''), $rawCategories)))));

return $categories;
}

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;
}
}
47 changes: 37 additions & 10 deletions src/components/Editor/Properties/PropertySelectMultiple.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -99,6 +102,10 @@ export default {
type: Boolean,
default: false,
},
customLabelHeading: {
type: String,
default: 'Custom Categories',
},
},
data() {
return {
Expand All @@ -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)
}
Expand All @@ -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 = []
}
Expand All @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -41,14 +41,17 @@ export default {
},
},
computed: {
isGroupLabel() {
return this.option.$isLabel && this.option.$groupLabel
},
label() {
const option = this.option
logger.debug('Option render', { option })
if (typeof this.option === 'string') {
return this.option
}
return this.option.label
return this.option.$groupLabel ? this.option.$groupLabel : this.option.label
},
colorObject() {
return uidToColor(this.label)
Expand Down
6 changes: 6 additions & 0 deletions src/mixins/EditorMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand Down
3 changes: 2 additions & 1 deletion src/views/EditSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

Expand Down

0 comments on commit 8b37bad

Please sign in to comment.