diff --git a/appinfo/info.xml b/appinfo/info.xml index ec9226b9d..23aad36f7 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -64,11 +64,9 @@ OCA\Deck\Activity\DeckProvider - OCA\Deck\Provider\DeckProvider - Deck @@ -77,5 +75,9 @@ 10 - + + + OCA\Deck\DAV\CalendarPlugin + + diff --git a/appinfo/routes.php b/appinfo/routes.php index 6a1482cd8..4b273c464 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -26,9 +26,6 @@ 'routes' => [ ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], - ['name' => 'Config#get', 'url' => '/config', 'verb' => 'GET'], - ['name' => 'Config#setValue', 'url' => '/config/{key}', 'verb' => 'POST'], - // boards ['name' => 'board#index', 'url' => '/boards', 'verb' => 'GET'], ['name' => 'board#create', 'url' => '/boards', 'verb' => 'POST'], @@ -125,17 +122,17 @@ ['name' => 'attachment_api#delete', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}', 'verb' => 'DELETE'], ['name' => 'attachment_api#restore', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'], - - ['name' => 'board_api#preflighted_cors', 'url' => '/api/v1.0/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']], ], 'ocs' => [ + ['name' => 'Config#get', 'url' => '/api/v1.0/config', 'verb' => 'GET'], + ['name' => 'Config#setValue', 'url' => '/api/v1.0/config/{key}', 'verb' => 'POST'], + ['name' => 'comments_api#list', 'url' => '/api/v1.0/cards/{cardId}/comments', 'verb' => 'GET'], ['name' => 'comments_api#create', 'url' => '/api/v1.0/cards/{cardId}/comments', 'verb' => 'POST'], ['name' => 'comments_api#update', 'url' => '/api/v1.0/cards/{cardId}/comments/{commentId}', 'verb' => 'PUT'], ['name' => 'comments_api#delete', 'url' => '/api/v1.0/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'], - // dashboard ['name' => 'overview_api#upcomingCards', 'url' => '/api/v1.0/overview/upcoming', 'verb' => 'GET'], ] ]; diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php index 342ac3fea..b64e23106 100644 --- a/lib/Controller/ConfigController.php +++ b/lib/Controller/ConfigController.php @@ -23,90 +23,42 @@ namespace OCA\Deck\Controller; +use OCA\Deck\Service\ConfigService; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\NotFoundResponse; -use OCP\IConfig; -use OCP\IGroup; -use OCP\IGroupManager; +use OCP\AppFramework\OCSController; use OCP\IRequest; -use OCP\AppFramework\Controller; -class ConfigController extends Controller { - private $config; - private $userId; - private $groupManager; +class ConfigController extends OCSController { + private $configService; public function __construct( $AppName, IRequest $request, - IConfig $config, - IGroupManager $groupManager, - $userId + ConfigService $configService ) { parent::__construct($AppName, $request); - $this->userId = $userId; - $this->groupManager = $groupManager; - $this->config = $config; + $this->configService = $configService; } /** * @NoCSRFRequired + * @NoAdminRequired */ - public function get() { - $data = [ - 'groupLimit' => $this->getGroupLimit(), - ]; - return new DataResponse($data); + public function get(): DataResponse { + return new DataResponse($this->configService->getAll()); } /** * @NoCSRFRequired + * @NoAdminRequired */ - public function setValue($key, $value) { - switch ($key) { - case 'groupLimit': - $result = $this->setGroupLimit($value); - break; - } + public function setValue(string $key, $value) { + $result = $this->configService->set($key, $value); if ($result === null) { return new NotFoundResponse(); } return new DataResponse($result); } - - private function setGroupLimit($value) { - $groups = []; - foreach ($value as $group) { - $groups[] = $group['id']; - } - $data = implode(',', $groups); - $this->config->setAppValue($this->appName, 'groupLimit', $data); - return $groups; - } - - private function getGroupLimitList() { - $value = $this->config->getAppValue($this->appName, 'groupLimit', ''); - $groups = explode(',', $value); - if ($value === '') { - return []; - } - return $groups; - } - - private function getGroupLimit() { - $groups = $this->getGroupLimitList(); - $groups = array_map(function ($groupId) { - /** @var IGroup $groups */ - $group = $this->groupManager->get($groupId); - if ($group === null) { - return null; - } - return [ - 'id' => $group->getGID(), - 'displayname' => $group->getDisplayName(), - ]; - }, $groups); - return array_filter($groups); - } } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 2e6a32e6e..68b603d10 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -24,34 +24,33 @@ namespace OCA\Deck\Controller; use OCA\Deck\AppInfo\Application; +use OCA\Deck\Service\ConfigService; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\IInitialStateService; use OCP\IRequest; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Controller; -use OCP\IL10N; class PageController extends Controller { private $permissionService; private $userId; private $l10n; private $initialState; + private $configService; public function __construct( $AppName, IRequest $request, PermissionService $permissionService, IInitialStateService $initialStateService, - IL10N $l10n, - $userId + ConfigService $configService ) { parent::__construct($AppName, $request); - $this->userId = $userId; $this->permissionService = $permissionService; $this->initialState = $initialStateService; - $this->l10n = $l10n; + $this->configService = $configService; } /** @@ -64,6 +63,7 @@ public function __construct( public function index() { $this->initialState->provideInitialState(Application::APP_ID, 'maxUploadSize', (int)\OCP\Util::uploadLimit()); $this->initialState->provideInitialState(Application::APP_ID, 'canCreate', $this->permissionService->canCreate()); + $this->initialState->provideInitialState(Application::APP_ID, 'config', $this->configService->getAll()); $response = new TemplateResponse('deck', 'main'); diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php new file mode 100644 index 000000000..30d6b4024 --- /dev/null +++ b/lib/DAV/Calendar.php @@ -0,0 +1,211 @@ + + * + * @author Georg Ehrke + * + * @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\Deck\DAV; + +use OCA\DAV\CalDAV\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Plugin; +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\Board; +use Sabre\CalDAV\CalendarQueryValidator; +use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\PropPatch; +use Sabre\VObject\InvalidDataException; +use Sabre\VObject\Reader; + +class Calendar extends ExternalCalendar { + + /** @var string */ + private $principalUri; + /** @var string[] */ + private $children; + /** @var DeckCalendarBackend */ + private $backend; + /** @var Board */ + private $board; + + public function __construct(string $principalUri, string $calendarUri, Board $board, DeckCalendarBackend $backend) { + parent::__construct('deck', $calendarUri); + + $this->backend = $backend; + $this->board = $board; + + $this->principalUri = $principalUri; + + if ($board) { + $this->children = $this->backend->getChildren($board->getId()); + } else { + $this->children = []; + } + } + + public function getOwner() { + return $this->principalUri; + } + + public function getACL() { + $acl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ] + ]; + if ($this->backend->checkBoardPermission($this->board->getId(), Acl::PERMISSION_MANAGE)) { + $acl[] = [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } + return $acl; + } + + public function setACL(array $acl) { + throw new Forbidden('Setting ACL is not supported on this node'); + } + + public function getSupportedPrivilegeSet() { + return null; + } + + public function calendarQuery(array $filters) { + $result = []; + $objects = $this->getChildren(); + + foreach ($objects as $object) { + if ($this->validateFilterForObject($object, $filters)) { + $result[] = $object->getName(); + } + } + + return $result; + } + + protected function validateFilterForObject($object, array $filters) { + $vObject = Reader::read($object->get()); + + $validator = new CalendarQueryValidator(); + $result = $validator->validate($vObject, $filters); + + // Destroy circular references so PHP will GC the object. + $vObject->destroy(); + + return $result; + } + + public function createFile($name, $data = null) { + throw new Forbidden('Creating a new entry is not implemented'); + } + + public function getChild($name) { + if ($this->childExists($name)) { + $card = array_values(array_filter( + $this->children, + function ($card) use (&$name) { + return $card->getCalendarPrefix() . '-' . $card->getId() . '.ics' === $name; + } + )); + if (count($card) > 0) { + return new CalendarObject($this, $name, $this->backend, $card[0]); + } + } + throw new NotFound('Node not found'); + } + + public function getChildren() { + $childNames = array_map(function ($card) { + return $card->getCalendarPrefix() . '-' . $card->getId() . '.ics'; + }, $this->children); + + $children = []; + + foreach ($childNames as $name) { + $children[] = $this->getChild($name); + } + + return $children; + } + + public function childExists($name) { + return count(array_filter( + $this->children, + function ($card) use (&$name) { + return $card->getCalendarPrefix() . '-' . $card->getId() . '.ics' === $name; + } + )) > 0; + } + + + public function delete() { + throw new Forbidden('Deleting an entry is not implemented'); + } + + public function getLastModified() { + return $this->board->getLastModified(); + } + + public function getGroup() { + return []; + } + + public function propPatch(PropPatch $propPatch) { + $properties = [ + '{DAV:}displayname', + '{http://apple.com/ns/ical/}calendar-color' + ]; + $propPatch->handle($properties, function ($properties) { + foreach ($properties as $key => $value) { + switch ($key) { + case '{DAV:}displayname': + if (mb_strpos($value, 'Deck: ') === 0) { + $value = mb_substr($value, strlen('Deck: ')); + } + $this->board->setTitle($value); + break; + case '{http://apple.com/ns/ical/}calendar-color': + $color = substr($value, 1, 6); + if (!preg_match('/[a-f0-9]{6}/i', $color)) { + throw new InvalidDataException('No valid color provided'); + } + $this->board->setColor($color); + break; + } + } + return $this->backend->updateBoard($this->board); + }); + // We can just return here and let oc_properties handle everything + } + + /** + * @inheritDoc + */ + public function getProperties($properties) { + return [ + '{DAV:}displayname' => 'Deck: ' . ($this->board ? $this->board->getTitle() : 'no board object provided'), + '{http://apple.com/ns/ical/}calendar-color' => '#' . $this->board->getColor(), + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO']), + ]; + } +} diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php new file mode 100644 index 000000000..53b55731e --- /dev/null +++ b/lib/DAV/CalendarObject.php @@ -0,0 +1,110 @@ + + * + * @author Georg Ehrke + * + * @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\Deck\DAV; + +use OCA\Deck\Db\Card; +use OCA\Deck\Db\Stack; +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAVACL\IACL; +use Sabre\VObject\Component\VCalendar; + +class CalendarObject implements ICalendarObject, IACL { + + /** @var Calendar */ + private $calendar; + /** @var string */ + private $name; + /** @var Card|Stack */ + private $sourceItem; + /** @var DeckCalendarBackend */ + private $backend; + /** @var VCalendar */ + private $calendarObject; + + public function __construct(Calendar $calendar, string $name, DeckCalendarBackend $backend, $sourceItem) { + $this->calendar = $calendar; + $this->name = $name; + $this->sourceItem = $sourceItem; + $this->backend = $backend; + $this->calendarObject = $this->sourceItem->getCalendarObject(); + } + + public function getOwner() { + return null; + } + + public function getGroup() { + return null; + } + + public function getACL() { + return $this->calendar->getACL(); + } + + public function setACL(array $acl) { + throw new Forbidden('Setting ACL is not supported on this node'); + } + + public function getSupportedPrivilegeSet() { + return null; + } + + public function put($data) { + throw new Forbidden('This calendar-object is read-only'); + } + + public function get() { + if ($this->sourceItem) { + return $this->calendarObject->serialize(); + } + } + + public function getContentType() { + return 'text/calendar; charset=utf-8'; + } + + public function getETag() { + return '"' . md5($this->sourceItem->getLastModified()) . '"'; + } + + public function getSize() { + return mb_strlen($this->calendarObject->serialize()); + } + + public function delete() { + throw new Forbidden('This calendar-object is read-only'); + } + + public function getName() { + return $this->name; + } + + public function setName($name) { + throw new Forbidden('This calendar-object is read-only'); + } + + public function getLastModified() { + return $this->sourceItem->getLastModified(); + } +} diff --git a/lib/DAV/CalendarPlugin.php b/lib/DAV/CalendarPlugin.php new file mode 100644 index 000000000..76da1929a --- /dev/null +++ b/lib/DAV/CalendarPlugin.php @@ -0,0 +1,84 @@ + + * + * @author Georg Ehrke + * + * @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\Deck\DAV; + +use OCA\DAV\CalDAV\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Integration\ICalendarProvider; +use OCA\Deck\Db\Board; +use OCA\Deck\Service\ConfigService; +use Sabre\DAV\Exception\NotFound; + +class CalendarPlugin implements ICalendarProvider { + + /** @var DeckCalendarBackend */ + private $backend; + /** @var bool */ + private $calendarIntegrationEnabled; + + public function __construct(DeckCalendarBackend $backend, ConfigService $configService) { + $this->backend = $backend; + $this->calendarIntegrationEnabled = $configService->get('calendar'); + } + + public function getAppId(): string { + return 'deck'; + } + + public function fetchAllForCalendarHome(string $principalUri): array { + if (!$this->calendarIntegrationEnabled) { + return []; + } + + return array_map(function (Board $board) use ($principalUri) { + return new Calendar($principalUri, 'board-' . $board->getId(), $board, $this->backend); + }, $this->backend->getBoards()); + } + + public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool { + if (!$this->calendarIntegrationEnabled) { + return false; + } + + $boards = array_map(static function (Board $board) { + return 'board-' . $board->getId(); + }, $this->backend->getBoards()); + return in_array($calendarUri, $boards, true); + } + + public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar { + if (!$this->calendarIntegrationEnabled) { + return null; + } + + if ($this->hasCalendarInCalendarHome($principalUri, $calendarUri)) { + try { + $board = $this->backend->getBoard((int)str_replace('board-', '', $calendarUri)); + return new Calendar($principalUri, $calendarUri, $board, $this->backend); + } catch (NotFound $e) { + // We can just return null if we have no matching board + } + } + return null; + } +} diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php new file mode 100644 index 000000000..6d3ca0239 --- /dev/null +++ b/lib/DAV/DeckCalendarBackend.php @@ -0,0 +1,89 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\DAV; + +use OCA\Deck\Db\Board; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\CardService; +use OCA\Deck\Service\PermissionService; +use OCA\Deck\Service\StackService; +use Sabre\DAV\Exception\NotFound; + +class DeckCalendarBackend { + + /** @var BoardService */ + private $boardService; + /** @var StackService */ + private $stackService; + /** @var CardService */ + private $cardService; + /** @var PermissionService */ + private $permissionService; + /** @var BoardMapper */ + private $boardMapper; + + public function __construct( + BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService, + BoardMapper $boardMapper + ) { + $this->boardService = $boardService; + $this->stackService = $stackService; + $this->cardService = $cardService; + $this->permissionService = $permissionService; + $this->boardMapper = $boardMapper; + } + + public function getBoards(): array { + return $this->boardService->findAll(); + } + + public function getBoard(int $id): Board { + try { + return $this->boardService->find($id); + } catch (\Exception $e) { + throw new NotFound('Board with id ' . $id . ' not found'); + } + } + + public function checkBoardPermission(int $id, int $permission): bool { + $permissions = $this->permissionService->getPermissions($id); + return isset($permissions[$permission]) ? $permissions[$permission] : false; + } + + public function updateBoard(Board $board): bool { + $this->boardMapper->update($board); + return true; + } + + public function getChildren(int $id): array { + return array_merge( + $this->cardService->findCalendarEntries($id), + $this->stackService->findCalendarEntries($id) + ); + } +} diff --git a/lib/Db/Card.php b/lib/Db/Card.php index 36faa3dd6..4e24719ba 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -24,6 +24,8 @@ namespace OCA\Deck\Db; use DateTime; +use DateTimeZone; +use Sabre\VObject\Component\VCalendar; class Card extends RelationalEntity { protected $title; @@ -117,4 +119,40 @@ public function jsonSerialize() { unset($json['descriptionPrev']); return $json; } + + public function getCalendarObject(): VCalendar { + $calendar = new VCalendar(); + $event = $calendar->createComponent('VTODO'); + $event->UID = 'deck-card-' . $this->getId(); + if ($this->getDuedate()) { + $creationDate = new DateTime(); + $creationDate->setTimestamp($this->createdAt); + $event->DTSTAMP = $creationDate; + $event->DUE = new DateTime($this->getDuedate(true), new DateTimeZone('UTC')); + } + $event->add('RELATED-TO', 'deck-stack-' . $this->getStackId()); + + // FIXME: For write support: CANCELLED / IN-PROCESS handling + $event->STATUS = $this->getArchived() ? "COMPLETED" : "NEEDS-ACTION"; + if ($this->getArchived()) { + $date = new DateTime(); + $date->setTimestamp($this->getLastModified()); + $event->COMPLETED = $date; + //$event->add('PERCENT-COMPLETE', 100); + } + if (count($this->getLabels()) > 0) { + $event->CATEGORIES = array_map(function ($label) { + return $label->getTitle(); + }, $this->getLabels()); + } + + $event->SUMMARY = $this->getTitle(); + $event->DESCRIPTION = $this->getDescription(); + $calendar->add($event); + return $calendar; + } + + public function getCalendarPrefix(): string { + return 'card'; + } } diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index 453b987cb..1eff81145 100644 --- a/lib/Db/CardMapper.php +++ b/lib/Db/CardMapper.php @@ -23,7 +23,9 @@ namespace OCA\Deck\Db; +use Exception; use OCP\AppFramework\Db\Entity; + use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -81,16 +83,20 @@ public function update(Entity $entity, $updateModified = true): Entity { // make sure we only reset the notification flag if the duedate changes if (in_array('duedate', $entity->getUpdatedFields(), true)) { - $existing = $this->find($entity->getId()); - if ($existing->getDuedate() !== $entity->getDuedate()) { - $entity->setNotified(false); + try { + /** @var Card $existing */ + $existing = $this->find($entity->getId()); + if ($existing && $entity->getDuedate() !== $existing->getDuedate()) { + $entity->setNotified(false); + } + // remove pending notifications + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('deck') + ->setObject('card', $entity->getId()); + $this->notificationManager->markProcessed($notification); + } catch (Exception $e) { } - // remove pending notifications - $notification = $this->notificationManager->createNotification(); - $notification - ->setApp('deck') - ->setObject('card', $entity->getId()); - $this->notificationManager->markProcessed($notification); } return parent::update($entity); } @@ -102,19 +108,13 @@ public function markNotified(Card $card): Entity { return parent::update($cardUpdate); } - /** - * @param $id - * @return RelationalEntity if not found - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException - * @throws \OCP\AppFramework\Db\DoesNotExistException - */ - public function find($id): Entity { + public function find($id): Card { $qb = $this->db->getQueryBuilder(); - $qb->select('*')->from('deck_cards') + $qb->select('*') + ->from('deck_cards') ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) ->orderBy('order') ->addOrderBy('id'); - /** @var Card $card */ $card = $this->findEntity($qb); $labels = $this->labelMapper->findAssignedLabelsForCard($card->id); @@ -153,7 +153,6 @@ public function queryCardsByBoards(array $boardIds): IQueryBuilder { ->from('deck_cards', 'c') ->innerJoin('c', 'deck_stacks', 's', $qb->expr()->eq('s.id', 'c.stack_id')) ->andWhere($qb->expr()->in('s.board_id', $qb->createNamedParameter($boardIds, IQueryBuilder::PARAM_INT_ARRAY))); - return $qb; } @@ -167,6 +166,19 @@ public function findDeleted($boardId, $limit = null, $offset = null) { return $this->findEntities($qb); } + public function findCalendarEntries($boardId, $limit = null, $offset = null) { + $qb = $this->db->getQueryBuilder(); + $qb->select('c.*') + ->from('deck_cards', 'c') + ->join('c', 'deck_stacks', 's', 's.id = c.stack_id') + ->where($qb->expr()->eq('s.board_id', $qb->createNamedParameter($boardId))) + ->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter('0'))) + ->orderBy('c.duedate') + ->setMaxResults($limit) + ->setFirstResult($offset); + return $this->findEntities($qb); + } + public function findAllArchived($stackId, $limit = null, $offset = null) { $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -278,19 +290,21 @@ public function deleteByStack($stackId) { } public function assignLabel($card, $label) { - $sql = 'INSERT INTO `*PREFIX*deck_assigned_labels` (`label_id`,`card_id`) VALUES (?,?)'; - $stmt = $this->db->prepare($sql); - $stmt->bindParam(1, $label, \PDO::PARAM_INT); - $stmt->bindParam(2, $card, \PDO::PARAM_INT); - $stmt->execute(); + $qb = $this->db->getQueryBuilder(); + $qb->insert('deck_assigned_labels') + ->values([ + 'label_id' => $qb->createNamedParameter($label, IQueryBuilder::PARAM_INT), + 'card_id' => $qb->createNamedParameter($card, IQueryBuilder::PARAM_INT), + ]); + $qb->execute(); } public function removeLabel($card, $label) { - $sql = 'DELETE FROM `*PREFIX*deck_assigned_labels` WHERE card_id = ? AND label_id = ?'; - $stmt = $this->db->prepare($sql); - $stmt->bindParam(1, $card, \PDO::PARAM_INT); - $stmt->bindParam(2, $label, \PDO::PARAM_INT); - $stmt->execute(); + $qb = $this->db->getQueryBuilder(); + $qb->delete('deck_assigned_labels') + ->where($qb->expr()->eq('card_id', $qb->createNamedParameter($card, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('label_id', $qb->createNamedParameter($label, IQueryBuilder::PARAM_INT))); + $qb->execute(); } public function isOwner($userId, $cardId) { diff --git a/lib/Db/DeckMapper.php b/lib/Db/DeckMapper.php index e3a9b4a05..2dd0dd990 100644 --- a/lib/Db/DeckMapper.php +++ b/lib/Db/DeckMapper.php @@ -29,10 +29,11 @@ * Class DeckMapper * * @package OCA\Deck\Db + * @deprecated use QBMapper * * TODO: Move to QBMapper once Nextcloud 14 is a minimum requirement */ -abstract class DeckMapper extends Mapper { +class DeckMapper extends Mapper { /** * @param $id diff --git a/lib/Db/Stack.php b/lib/Db/Stack.php index 85d9c4553..9790e6b7c 100644 --- a/lib/Db/Stack.php +++ b/lib/Db/Stack.php @@ -23,6 +23,8 @@ namespace OCA\Deck\Db; +use Sabre\VObject\Component\VCalendar; + class Stack extends RelationalEntity { protected $title; protected $boardId; @@ -50,4 +52,17 @@ public function jsonSerialize() { } return $json; } + + public function getCalendarObject(): VCalendar { + $calendar = new VCalendar(); + $event = $calendar->createComponent('VTODO'); + $event->UID = 'deck-stack-' . $this->getId(); + $event->SUMMARY = 'List : ' . $this->getTitle(); + $calendar->add($event); + return $calendar; + } + + public function getCalendarPrefix(): string { + return 'stack'; + } } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index df7ee1c4c..91c0999bf 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -130,6 +130,7 @@ public function findAll($since = -1, $details = null) { return $this->boardsCache; } $complete = $this->getUserBoards($since); + $result = []; /** @var Board $item */ foreach ($complete as &$item) { $this->boardMapper->mapOwner($item); @@ -152,7 +153,7 @@ public function findAll($since = -1, $details = null) { ]); $result[$item->getId()] = $item; } - $this->boardsCache = $complete; + $this->boardsCache = $result; return array_values($result); } @@ -189,6 +190,7 @@ public function find($boardId) { 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false ]); $this->enrichWithUsers($board); + $this->boardsCache[$board->getId()] = $board; return $board; } @@ -348,7 +350,7 @@ public function delete($id) { throw new BadRequestException('board id must be a number'); } - $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); + $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_MANAGE); $board = $this->find($id); if ($board->getDeletedAt() > 0) { throw new BadRequestException('This board has already been deleted'); @@ -377,7 +379,7 @@ public function deleteUndo($id) { throw new BadRequestException('board id must be a number'); } - $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); + $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_MANAGE); $board = $this->find($id); $board->setDeletedAt(0); $board = $this->boardMapper->update($board); @@ -404,7 +406,7 @@ public function deleteForce($id) { throw new BadRequestException('id must be a number'); } - $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); + $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_MANAGE); $board = $this->find($id); $delete = $this->boardMapper->delete($board); diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 50504bc07..bd9c11e0c 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -144,6 +144,15 @@ public function find($cardId) { return $card; } + public function findCalendarEntries($boardId) { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + $cards = $this->cardMapper->findCalendarEntries($boardId); + foreach ($cards as $card) { + $this->enrich($card); + } + return $cards; + } + /** * @param $title * @param $stackId diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php new file mode 100644 index 000000000..baf1e4b8f --- /dev/null +++ b/lib/Service/ConfigService.php @@ -0,0 +1,129 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Service; + +use OCA\Deck\AppInfo\Application; +use OCA\Deck\NoPermissionException; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; + +class ConfigService { + private $config; + private $userId; + private $groupManager; + + public function __construct( + IConfig $config, + IGroupManager $groupManager, + $userId + ) { + $this->userId = $userId; + $this->groupManager = $groupManager; + $this->config = $config; + } + + public function getAll(): array { + $data = [ + 'calendar' => $this->get('calendar') + ]; + if ($this->groupManager->isAdmin($this->userId)) { + $data = [ + 'groupLimit' => $this->get('groupLimit'), + ]; + } + return $data; + } + + public function get($key) { + $result = null; + switch ($key) { + case 'groupLimit': + if (!$this->groupManager->isAdmin($this->userId)) { + throw new NoPermissionException('You must be admin to get the group limit'); + } + $result = $this->getGroupLimit(); + break; + case 'calendar': + $result = (bool)$this->config->getUserValue($this->userId, Application::APP_ID, 'calendar', true); + break; + } + return $result; + } + + public function set($key, $value) { + $result = null; + switch ($key) { + case 'groupLimit': + if (!$this->groupManager->isAdmin($this->userId)) { + throw new NoPermissionException('You must be admin to set the group limit'); + } + $result = $this->setGroupLimit($value); + break; + case 'calendar': + $this->config->setUserValue($this->userId, Application::APP_ID, 'calendar', (int)$value); + $result = $value; + break; + } + return $result; + } + + private function setGroupLimit($value) { + $groups = []; + foreach ($value as $group) { + $groups[] = $group['id']; + } + $data = implode(',', $groups); + $this->config->setAppValue(Application::APP_ID, 'groupLimit', $data); + return $groups; + } + + private function getGroupLimitList() { + $value = $this->config->getAppValue(Application::APP_ID, 'groupLimit', ''); + $groups = explode(',', $value); + if ($value === '') { + return []; + } + return $groups; + } + + private function getGroupLimit() { + $groups = $this->getGroupLimitList(); + $groups = array_map(function ($groupId) { + /** @var IGroup $groups */ + $group = $this->groupManager->get($groupId); + if ($group === null) { + return null; + } + return [ + 'id' => $group->getGID(), + 'displayname' => $group->getDisplayName(), + ]; + }, $groups); + return array_filter($groups); + } +} diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index 7df627e06..a31d13854 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -146,6 +146,11 @@ public function findAll($boardId, $since = -1) { return $stacks; } + public function findCalendarEntries($boardId) { + $this->permissionService->checkPermission(null, $boardId, Acl::PERMISSION_READ); + return $this->stackMapper->findAll($boardId); + } + public function fetchDeleted($boardId) { $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); $stacks = $this->stackMapper->findDeleted($boardId); diff --git a/src/components/navigation/AppNavigation.vue b/src/components/navigation/AppNavigation.vue index 99f85f15b..8d7cd47ea 100644 --- a/src/components/navigation/AppNavigation.vue +++ b/src/components/navigation/AppNavigation.vue @@ -52,14 +52,28 @@ @@ -84,7 +100,8 @@ import { AppNavigation as AppNavigationVue, AppNavigationItem, AppNavigationSett import AppNavigationAddBoard from './AppNavigationAddBoard' import AppNavigationBoardCategory from './AppNavigationBoardCategory' import { loadState } from '@nextcloud/initial-state' -import { generateUrl, generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' const canCreateState = loadState('deck', 'canCreate') @@ -123,8 +140,7 @@ export default { 'sharedBoards', ]), isAdmin() { - // eslint-disable-next-line - return OC.isUserAdmin() + return !!getCurrentUser()?.isAdmin }, cardDetailsInModal: { get() { @@ -134,15 +150,19 @@ export default { this.$store.dispatch('setCardDetailsInModal', newValue) }, }, + configCalendar: { + get() { + return this.$store.getters.config('calendar') + }, + set(newValue) { + this.$store.dispatch('setConfig', { calendar: newValue }) + }, + }, }, beforeMount() { if (this.isAdmin) { - axios.get(generateUrl('apps/deck/config')).then((response) => { - this.groupLimit = response.data.groupLimit - this.groupLimitDisabled = false - }, (error) => { - console.error('Error while loading groupLimit', error.response) - }) + this.groupLimit = this.$store.getters.config('groupLimit') + this.groupLimitDisabled = false axios.get(generateOcsUrl('cloud', 2) + 'groups').then((response) => { this.groups = response.data.ocs.data.groups.reduce((obj, item) => { obj.push({ @@ -157,15 +177,9 @@ export default { } }, methods: { - updateConfig() { - this.groupLimitDisabled = true - axios.post(generateUrl('apps/deck/config/groupLimit'), { - value: this.groupLimit, - }).then(() => { - this.groupLimitDisabled = false - }, (error) => { - console.error('Error while saving groupLimit', error.response) - }) + async updateConfig() { + await this.$store.dispatch('setConfig', { groupLimit: this.groupLimit }) + this.groupLimitDisabled = false }, }, } diff --git a/src/store/main.js b/src/store/main.js index 99b313e05..dc25160ab 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -22,6 +22,7 @@ import 'url-search-params-polyfill' +import { loadState } from '@nextcloud/initial-state' import Vue from 'vue' import Vuex from 'vuex' import axios from '@nextcloud/axios' @@ -56,6 +57,7 @@ export default new Vuex.Store({ }, strict: debug, state: { + config: loadState('deck', 'config', {}), showArchived: false, navShown: true, compactMode: localStorage.getItem('deck.compactMode') === 'true', @@ -73,6 +75,9 @@ export default new Vuex.Store({ filter: { tags: [], users: [], due: '' }, }, getters: { + config: state => (key) => { + return state.config[key] + }, cardDetailsInModal: state => { return state.cardDetailsInModal }, @@ -133,6 +138,9 @@ export default new Vuex.Store({ }, }, mutations: { + SET_CONFIG(state, { key, value }) { + Vue.set(state.config, key, value) + }, setSearchQuery(state, searchQuery) { state.searchQuery = searchQuery }, @@ -287,6 +295,19 @@ export default new Vuex.Store({ }, actions: { + async setConfig({ commit }, config) { + for (const key in config) { + try { + await axios.post(generateOcsUrl(`apps/deck/api/v1.0/config`) + key, { + value: config[key], + }) + commit('SET_CONFIG', { key, value: config[key] }) + } catch (e) { + console.error(`Error while saving ${key}`, e.response) + throw e + } + } + }, setFilter({ commit }, filter) { commit('SET_FILTER', filter) }, diff --git a/tests/unit/controller/PageControllerTest.php b/tests/unit/controller/PageControllerTest.php index d84724e4a..4cc9e0c8b 100644 --- a/tests/unit/controller/PageControllerTest.php +++ b/tests/unit/controller/PageControllerTest.php @@ -24,40 +24,33 @@ namespace OCA\Deck\Controller; +use OCA\Deck\Service\ConfigService; use OCA\Deck\Service\PermissionService; use OCP\IInitialStateService; use OCP\IL10N; use OCP\IRequest; -use OCA\Deck\Db\Board; -use OCP\IConfig; class PageControllerTest extends \Test\TestCase { private $controller; private $request; private $l10n; - private $userId = 'john'; private $permissionService; private $initialState; - private $config; + private $configService; public function setUp(): void { $this->l10n = $this->createMock(IL10N::class); $this->request = $this->createMock(IRequest::class); $this->permissionService = $this->createMock(PermissionService::class); - $this->config = $this->createMock(IConfig::class); + $this->configService = $this->createMock(ConfigService::class); $this->initialState = $this->createMock(IInitialStateService::class); $this->controller = new PageController( - 'deck', $this->request, $this->permissionService, $this->initialState, $this->l10n, $this->userId + 'deck', $this->request, $this->permissionService, $this->initialState, $this->configService ); } public function testIndex() { - $board = new Board(); - $board->setTitle('Personal'); - $board->setOwner($this->userId); - $board->setColor('317CCC'); - $this->permissionService->expects($this->any()) ->method('canCreate') ->willReturn(true);