diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php index dbc90f9fae..87aee562b7 100644 --- a/lib/DAV/Calendar.php +++ b/lib/DAV/Calendar.php @@ -10,6 +10,8 @@ use OCA\DAV\CalDAV\Plugin; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Board; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\Stack; use Sabre\CalDAV\CalendarQueryValidator; use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; use Sabre\DAV\Exception\Forbidden; @@ -22,18 +24,21 @@ class Calendar extends ExternalCalendar { /** @var string */ private $principalUri; - /** @var string[] */ - private $children; + /** @var array|null */ + private $children = null; /** @var DeckCalendarBackend */ private $backend; /** @var Board */ private $board; + /** @var Stack|null */ + private $stack; - public function __construct(string $principalUri, string $calendarUri, Board $board, DeckCalendarBackend $backend) { + public function __construct(string $principalUri, string $calendarUri, Board $board, DeckCalendarBackend $backend, ?Stack $stack = null) { parent::__construct('deck', $calendarUri); $this->backend = $backend; $this->board = $board; + $this->stack = $stack; $this->principalUri = $principalUri; } @@ -42,21 +47,36 @@ public function getOwner() { return $this->principalUri; } + public function isShared(): bool { + return false; + } + public function getACL() { - // the calendar should always have the read and the write-properties permissions - // write-properties is needed to allow the user to toggle the visibility of shared deck calendars + // Always allow read. Only expose write capabilities when the current + // principal can edit/manage the underlying board. $acl = [ [ 'privilege' => '{DAV:}read', 'principal' => $this->getOwner(), 'protected' => true, ], - [ + ]; + if ($this->backend->checkBoardPermission($this->board->getId(), Acl::PERMISSION_EDIT)) { + $acl[] = [ + 'privilege' => '{DAV:}write', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } + // write-properties is needed to allow users with manage permission to + // toggle calendar visibility and update board-level metadata. + if ($this->backend->checkBoardPermission($this->board->getId(), Acl::PERMISSION_MANAGE)) { + $acl[] = [ 'privilege' => '{DAV:}write-properties', 'principal' => $this->getOwner(), 'protected' => true, - ] - ]; + ]; + } return $acl; } @@ -95,45 +115,82 @@ protected function validateFilterForObject($object, array $filters) { } public function createFile($name, $data = null) { - throw new Forbidden('Creating a new entry is not implemented'); + try { + $this->getChildNode($name, false, false)->put((string)$data); + $this->children = null; + return; + } catch (NotFound $e) { + // New object path, continue with create. + } + + $owner = $this->extractUserIdFromPrincipalUri(); + $this->backend->createCalendarObject( + $this->board->getId(), + $owner, + (string)$data, + $this->extractCardIdFromNormalizedName($name), + $this->stack?->getId() + ); + $this->children = null; } public function getChild($name) { - if ($this->childExists($name)) { - $card = array_values(array_filter( - $this->getBackendChildren(), - function ($card) use (&$name) { - return $card->getCalendarPrefix() . '-' . $card->getId() . '.ics' === $name; - } - )); - if (count($card) > 0) { - return new CalendarObject($this, $name, $this->backend, $card[0]); + return $this->getChildNode($name, true, true); + } + + private function getChildNode(string $name, bool $allowPlaceholder, bool $includeDeletedFallback) { + foreach ($this->getBackendChildren() as $item) { + $canonicalName = $item->getCalendarPrefix() . '-' . $item->getId() . '.ics'; + if ($this->isMatchingCalendarObjectName($name, $canonicalName)) { + return new CalendarObject($this, $canonicalName, $this->backend, $item); + } + } + + // Fallback for stale hrefs that are no longer part of the current + // children cache but still refer to a board-local object. + $fallbackItem = $this->backend->findCalendarObjectByName( + $name, + $this->board->getId(), + $this->stack?->getId(), + $includeDeletedFallback + ); + if ($fallbackItem !== null) { + $canonicalName = $fallbackItem->getCalendarPrefix() . '-' . $fallbackItem->getId() . '.ics'; + return new CalendarObject($this, $canonicalName, $this->backend, $fallbackItem); + } + + if ($allowPlaceholder && $this->shouldUsePlaceholderForMissingObject()) { + $placeholderItem = $this->buildPlaceholderCalendarObject($name); + if ($placeholderItem !== null) { + $canonicalName = $placeholderItem->getCalendarPrefix() . '-' . $placeholderItem->getId() . '.ics'; + return new CalendarObject($this, $canonicalName, $this->backend, $placeholderItem); } } + throw new NotFound('Node not found'); } public function getChildren() { - $childNames = array_map(function ($card) { - return $card->getCalendarPrefix() . '-' . $card->getId() . '.ics'; - }, $this->getBackendChildren()); - $children = []; - - foreach ($childNames as $name) { - $children[] = $this->getChild($name); + foreach ($this->getBackendChildren() as $item) { + $name = $item->getCalendarPrefix() . '-' . $item->getId() . '.ics'; + $children[] = new CalendarObject($this, $name, $this->backend, $item); } return $children; } private function getBackendChildren() { - if ($this->children) { + if ($this->children !== null) { return $this->children; } if ($this->board) { - $this->children = $this->backend->getChildren($this->board->getId()); + if ($this->stack !== null) { + $this->children = $this->backend->getChildrenForStack($this->stack->getId()); + } else { + $this->children = $this->backend->getChildren($this->board->getId()); + } } else { $this->children = []; } @@ -144,8 +201,9 @@ private function getBackendChildren() { public function childExists($name) { return count(array_filter( $this->getBackendChildren(), - function ($card) use (&$name) { - return $card->getCalendarPrefix() . '-' . $card->getId() . '.ics' === $name; + function ($item) use (&$name) { + $canonicalName = $item->getCalendarPrefix() . '-' . $item->getId() . '.ics'; + return $this->isMatchingCalendarObjectName($name, $canonicalName); } )) > 0; } @@ -156,6 +214,8 @@ public function delete() { } public function getLastModified() { + // Keep collection last-modified monotonic and avoid hash offsets that + // can move backwards for different fingerprints. return $this->board->getLastModified(); } @@ -163,6 +223,14 @@ public function getGroup() { return []; } + public function getBoardId(): int { + return $this->board->getId(); + } + + public function getStackId(): ?int { + return $this->stack?->getId(); + } + public function propPatch(PropPatch $propPatch) { $properties = [ '{DAV:}displayname', @@ -176,7 +244,7 @@ public function propPatch(PropPatch $propPatch) { throw new Forbidden('no permission to change the displayname'); } if (mb_strpos($value, 'Deck: ') === 0) { - $value = mb_substr($value, strlen('Deck: ')); + $value = mb_substr($value, mb_strlen('Deck: ')); } $this->board->setTitle($value); break; @@ -201,10 +269,113 @@ public function propPatch(PropPatch $propPatch) { * @inheritDoc */ public function getProperties($properties) { + $displayName = 'Deck: ' . ($this->board ? $this->board->getTitle() : 'no board object provided'); + if ($this->stack !== null) { + $displayName .= ' / ' . $this->stack->getTitle(); + } + return [ - '{DAV:}displayname' => 'Deck: ' . ($this->board ? $this->board->getTitle() : 'no board object provided'), + '{DAV:}displayname' => $displayName, '{http://apple.com/ns/ical/}calendar-color' => '#' . $this->board->getColor(), '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO']), ]; } + + private function extractUserIdFromPrincipalUri(): string { + if (preg_match('#^/?principals/users/([^/]+)$#', $this->principalUri, $matches) !== 1) { + throw new InvalidDataException('Invalid principal URI'); + } + + return $matches[1]; + } + + private function extractCardIdFromNormalizedName(string $name): ?int { + if (preg_match('/^(?:deck-)?card-(\d+)\.ics$/', $name, $matches) === 1) { + return (int)$matches[1]; + } + + return null; + } + + private function isMatchingCalendarObjectName(string $requestedName, string $canonicalName): bool { + if ($requestedName === $canonicalName) { + return true; + } + + if (str_starts_with($requestedName, 'deck-') && substr($requestedName, 5) === $canonicalName) { + return true; + } + + return str_starts_with($canonicalName, 'deck-') && substr($canonicalName, 5) === $requestedName; + } + + /** + * Prevent full REPORT failures on stale hrefs by returning a minimal placeholder + * object when clients request no-longer-existing calendar object names. + * + * @return Card|Stack|null + */ + private function buildPlaceholderCalendarObject(string $name) { + if (preg_match('/^(?:deck-)?card-(\d+)\.ics$/', $name, $matches) === 1) { + $cardId = (int)$matches[1]; + $card = $this->backend->findCalendarObjectByName($name, $this->board->getId(), $this->stack?->getId()); + if (!($card instanceof Card)) { + // Fallback for stale hrefs after cross-board moves. + $card = $this->backend->findCalendarObjectByName($name, null, null); + } + if (!($card instanceof Card)) { + return null; + } + + $placeholder = new Card(); + $placeholder->setId($cardId); + $placeholder->setTitle('Deleted task'); + $placeholder->setDescription(''); + $placeholder->setStackId($this->stack?->getId() ?? $card->getStackId()); + $cardType = (string)$card->getType(); + $placeholder->setType($cardType !== '' ? $cardType : 'plain'); + $placeholder->setOrder(0); + $placeholder->setCreatedAt($card->getCreatedAt() > 0 ? $card->getCreatedAt() : time()); + $placeholder->setLastModified(time()); + $placeholder->setDeletedAt(time()); + return $placeholder; + } + + if (preg_match('/^stack-(\d+)\.ics$/', $name, $matches) === 1) { + $stackId = (int)$matches[1]; + try { + $stack = $this->backend->getStack($stackId); + if ($stack->getBoardId() !== $this->board->getId()) { + return null; + } + } catch (\Throwable $e) { + return null; + } + + $stack = new Stack(); + $stack->setId($stackId); + $stack->setTitle('Deleted list'); + $stack->setBoardId($this->board->getId()); + $stack->setOrder(0); + $stack->setDeletedAt(time()); + $stack->setLastModified(time()); + return $stack; + } + + return null; + } + + private function shouldUsePlaceholderForMissingObject(): bool { + if (!class_exists('\OC')) { + return false; + } + + try { + $request = \OC::$server->getRequest(); + $method = strtoupper((string)$request->getMethod()); + return in_array($method, ['GET', 'HEAD', 'REPORT', 'PROPFIND'], true); + } catch (\Throwable $e) { + return false; + } + } } diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php index 4b8f6476cd..ac65e6484d 100644 --- a/lib/DAV/CalendarObject.php +++ b/lib/DAV/CalendarObject.php @@ -25,6 +25,8 @@ class CalendarObject implements ICalendarObject, IACL { private $backend; /** @var VCalendar */ private $calendarObject; + /** @var string|null */ + private $serializedData = null; public function __construct(Calendar $calendar, string $name, DeckCalendarBackend $backend, $sourceItem) { $this->calendar = $calendar; @@ -35,11 +37,11 @@ public function __construct(Calendar $calendar, string $name, DeckCalendarBacken } public function getOwner() { - return null; + return $this->calendar->getOwner(); } public function getGroup() { - return null; + return $this->calendar->getGroup(); } public function getACL() { @@ -55,12 +57,14 @@ public function getSupportedPrivilegeSet() { } public function put($data) { - throw new Forbidden('This calendar-object is read-only'); + $this->sourceItem = $this->backend->updateCalendarObject($this->sourceItem, $data); + $this->calendarObject = $this->sourceItem->getCalendarObject(); + $this->serializedData = null; } public function get() { if ($this->sourceItem) { - return $this->calendarObject->serialize(); + return $this->getSerializedData(); } } @@ -69,15 +73,15 @@ public function getContentType() { } public function getETag() { - return '"' . md5($this->sourceItem->getLastModified()) . '"'; + return '"' . md5($this->sourceItem->getLastModified() . '|' . $this->backend->getObjectRevisionFingerprint($this->sourceItem)) . '"'; } public function getSize() { - return mb_strlen($this->calendarObject->serialize()); + return mb_strlen($this->getSerializedData()); } public function delete() { - throw new Forbidden('This calendar-object is read-only'); + $this->backend->deleteCalendarObject($this->sourceItem, $this->calendar->getBoardId(), $this->calendar->getStackId()); } public function getName() { @@ -91,4 +95,13 @@ public function setName($name) { public function getLastModified() { return $this->sourceItem->getLastModified(); } + + private function getSerializedData(): string { + if ($this->serializedData === null) { + $this->backend->decorateCalendarObject($this->sourceItem, $this->calendarObject); + $this->serializedData = $this->calendarObject->serialize(); + } + + return $this->serializedData; + } } diff --git a/lib/DAV/CalendarPlugin.php b/lib/DAV/CalendarPlugin.php index e7c9ab6b20..cfad42d968 100644 --- a/lib/DAV/CalendarPlugin.php +++ b/lib/DAV/CalendarPlugin.php @@ -38,37 +38,76 @@ public function fetchAllForCalendarHome(string $principalUri): array { } $configService = $this->configService; - return array_map(function (Board $board) use ($principalUri) { - return new Calendar($principalUri, 'board-' . $board->getId(), $board, $this->backend); - }, array_filter($this->backend->getBoards(), function ($board) use ($configService) { + $boards = array_values(array_filter($this->backend->getBoards(), function ($board) use ($configService) { return $configService->isCalendarEnabled($board->getId()); })); + + if ($this->configService->getCalDavListMode() === ConfigService::SETTING_CALDAV_LIST_MODE_PER_LIST_CALENDAR) { + $calendars = []; + foreach ($boards as $board) { + foreach ($this->backend->getStacks($board->getId()) as $stack) { + $calendars[] = new Calendar($principalUri, 'stack-' . $stack->getId(), $board, $this->backend, $stack); + } + } + return $calendars; + } + + return array_map(function (Board $board) use ($principalUri) { + return new Calendar($principalUri, 'board-' . $board->getId(), $board, $this->backend); + }, $boards); } public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool { if (!$this->calendarIntegrationEnabled) { return false; } + $normalizedCalendarUri = $this->normalizeCalendarUri($calendarUri); - $boards = array_map(static function (Board $board) { - return 'board-' . $board->getId(); - }, $this->backend->getBoards()); - return in_array($calendarUri, $boards, true); + if ($this->configService->getCalDavListMode() === ConfigService::SETTING_CALDAV_LIST_MODE_PER_LIST_CALENDAR) { + foreach ($this->backend->getBoards() as $board) { + foreach ($this->backend->getStacks($board->getId()) as $stack) { + if ($normalizedCalendarUri === 'stack-' . $stack->getId()) { + return true; + } + } + } + return false; + } + + $boards = array_map(static fn (Board $board): string => 'board-' . $board->getId(), $this->backend->getBoards()); + return in_array($normalizedCalendarUri, $boards, true); } public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar { if (!$this->calendarIntegrationEnabled) { return null; } + $normalizedCalendarUri = $this->normalizeCalendarUri($calendarUri); - 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 + try { + if (str_starts_with($normalizedCalendarUri, 'stack-')) { + $stack = $this->backend->getStack((int)str_replace('stack-', '', $normalizedCalendarUri)); + $board = $this->backend->getBoard($stack->getBoardId()); + return new Calendar($principalUri, $normalizedCalendarUri, $board, $this->backend, $stack); } + + if (str_starts_with($normalizedCalendarUri, 'board-')) { + $board = $this->backend->getBoard((int)str_replace('board-', '', $normalizedCalendarUri)); + return new Calendar($principalUri, $normalizedCalendarUri, $board, $this->backend); + } + } catch (NotFound $e) { + // We can just return null if we have no matching board/stack } + return null; } + + private function normalizeCalendarUri(string $calendarUri): string { + $prefix = 'app-generated--deck--'; + if (str_starts_with($calendarUri, $prefix)) { + return substr($calendarUri, strlen($prefix)); + } + + return $calendarUri; + } } diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 73146b0dda..4fc17dc202 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -12,11 +12,19 @@ use OCA\Deck\Db\Board; use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\Stack; +use OCA\Deck\Model\OptionalNullableValue; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\CardService; +use OCA\Deck\Service\ConfigService; +use OCA\Deck\Service\LabelService; use OCA\Deck\Service\PermissionService; use OCA\Deck\Service\StackService; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\NotFound; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTodo; class DeckCalendarBackend { @@ -30,22 +38,29 @@ class DeckCalendarBackend { private $permissionService; /** @var BoardMapper */ private $boardMapper; + /** @var LabelService */ + private $labelService; + /** @var ConfigService */ + private $configService; public function __construct( BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService, - BoardMapper $boardMapper, + BoardMapper $boardMapper, LabelService $labelService, ConfigService $configService, ) { $this->boardService = $boardService; $this->stackService = $stackService; $this->cardService = $cardService; $this->permissionService = $permissionService; $this->boardMapper = $boardMapper; + $this->labelService = $labelService; + $this->configService = $configService; } public function getBoards(): array { return $this->boardService->findAll(-1, false, false); } + /** @psalm-suppress InvalidThrow */ public function getBoard(int $id): Board { try { return $this->boardService->find($id); @@ -70,4 +85,806 @@ public function getChildren(int $id): array { $this->stackService->findCalendarEntries($id) ); } + + /** @return Stack[] */ + public function getStacks(int $boardId): array { + return $this->stackService->findCalendarEntries($boardId); + } + + public function getStack(int $stackId): Stack { + return $this->stackService->find($stackId); + } + + /** + * Resolve a calendar object id from a CalDAV resource name, optionally + * constrained to the current board/stack context. + * + * @param string $name resource name like card-123.ics, deck-card-123.ics or stack-12.ics + * @return Card|Stack|null + */ + public function findCalendarObjectByName(string $name, ?int $boardId = null, ?int $stackId = null, bool $includeDeleted = true) { + if (preg_match('/^(?:deck-)?card-(\d+)\.ics$/', $name, $matches) === 1) { + $cardId = (int)$matches[1]; + $card = null; + try { + $card = $includeDeleted + ? $this->findCardByIdIncludingDeleted($cardId) + : $this->cardService->find($cardId); + } catch (\Throwable $e) { + $card = null; + } + if ($card === null) { + return null; + } + + try { + if ($stackId !== null && $card->getStackId() !== $stackId) { + return null; + } + if ($boardId !== null && $this->getBoardIdForCard($card) !== $boardId) { + return null; + } + } catch (\Throwable $e) { + return null; + } + + return $card; + } + + if (preg_match('/^stack-(\d+)\.ics$/', $name, $matches) === 1) { + try { + $stack = $this->stackService->find((int)$matches[1]); + if ($boardId !== null && $stack->getBoardId() !== $boardId) { + return null; + } + + return $stack; + } catch (\Throwable $e) { + return null; + } + } + + return null; + } + + /** @return Card[] */ + public function getChildrenForStack(int $stackId): array { + return $this->stackService->find($stackId)->getCards() ?? []; + } + + public function getCalDavListMode(): string { + return $this->configService->getCalDavListMode(); + } + + public function getCalendarRevisionFingerprint(int $boardId, ?int $stackId = null): string { + $mode = $this->configService->getCalDavListMode(); + $fingerprint = [$mode]; + $stacks = $this->stackService->findAll($boardId); + usort($stacks, static fn (Stack $a, Stack $b) => $a->getOrder() <=> $b->getOrder()); + + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY) { + foreach ($stacks as $stack) { + $fingerprint[] = $stack->getId() . ':' . $stack->getOrder() . ':' . $stack->getDeletedAt(); + } + } + + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY) { + foreach ($stacks as $stack) { + $fingerprint[] = $stack->getId() . ':' . $stack->getTitle() . ':' . $stack->getDeletedAt(); + } + } + + if ($stackId !== null) { + $fingerprint[] = 'stack:' . $stackId; + } + + return implode('|', $fingerprint); + } + + /** + * @param Card|Stack $sourceItem + */ + public function getObjectRevisionFingerprint($sourceItem): string { + $mode = $this->configService->getCalDavListMode(); + if (!($sourceItem instanceof Card)) { + return $mode; + } + + try { + $stack = $this->stackService->find($sourceItem->getStackId()); + $boardId = $stack->getBoardId(); + } catch (\Throwable $e) { + return $mode; + } + + $fingerprint = [$mode, 'stack:' . $stack->getId()]; + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY) { + $fingerprint[] = $stack->getTitle(); + $fingerprint[] = (string)$stack->getDeletedAt(); + } + + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY) { + $fingerprint[] = $this->getCalendarRevisionFingerprint($boardId); + } + + return implode('|', $fingerprint); + } + + public function createCalendarObject(int $boardId, string $owner, string $data, ?int $preferredCardId = null, ?int $preferredStackId = null): Card { + $todo = $this->extractTodo($data); + $existingCard = $this->findExistingCardByUid($todo); + if ($existingCard !== null) { + $existingBoardId = $this->getBoardIdForCardOrNull($existingCard); + if ($existingBoardId === null || $existingBoardId === $boardId) { + $restoreDeleted = $existingCard->getDeletedAt() > 0; + return $this->updateCardFromCalendar($existingCard, $data, $restoreDeleted, $boardId); + } + } + + if ($preferredCardId !== null) { + $cardById = $this->findCardByIdIncludingDeleted($preferredCardId); + if ($cardById !== null) { + $existingBoardId = $this->getBoardIdForCardOrNull($cardById); + if ($existingBoardId === null || $existingBoardId === $boardId) { + $restoreDeleted = $cardById->getDeletedAt() > 0; + return $this->updateCardFromCalendar($cardById, $data, $restoreDeleted, $boardId); + } + } + } + + $title = trim((string)($todo->SUMMARY ?? '')); + if ($title === '') { + $title = 'New task'; + } + + $mode = $this->configService->getCalDavListMode(); + $relatedStackId = $this->extractStackIdFromRelatedTo($todo); + if ($relatedStackId === null && $preferredStackId !== null) { + $relatedStackId = $preferredStackId; + } + if ($relatedStackId === null) { + $relatedStackId = $this->inferStackIdFromTodoHints($boardId, $todo, $mode); + } + $stackId = $this->resolveStackIdForBoard($boardId, $relatedStackId); + $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : ''; + $dueDate = isset($todo->DUE) ? new \DateTime($todo->DUE->getDateTime()->format('c')) : null; + + $card = $this->cardService->create( + $title, + $stackId, + 'plain', + 999, + $owner, + $description, + $dueDate + ); + + $done = $this->mapDoneFromTodo($todo, $card)->getValue(); + if ($done !== null) { + $card = $this->cardService->update( + $card->getId(), + $card->getTitle(), + $card->getStackId(), + $card->getType(), + $card->getOwner() ?? $owner, + $card->getDescription(), + $card->getOrder(), + $card->getDuedate() ? $card->getDuedate()->format('c') : null, + $card->getDeletedAt(), + $card->getArchived(), + new OptionalNullableValue($done) + ); + } + + $categories = $this->extractCategories($todo); + if ($categories !== null) { + $categories = $this->normalizeCategoriesForLabelSync($boardId, $categories, $mode); + $this->syncCardCategories($card->getId(), $categories); + } + + return $card; + } + + /** + * @param Card|Stack $sourceItem + * @return Card|Stack + */ + public function updateCalendarObject($sourceItem, string $data) { + if ($sourceItem instanceof Card) { + return $this->updateCardFromCalendar($sourceItem, $data); + } + + if ($sourceItem instanceof Stack) { + return $this->updateStackFromCalendar($sourceItem, $data); + } + + throw new \InvalidArgumentException('Unsupported calendar object source item'); + } + + /** + * @param Card|Stack $sourceItem + */ + public function deleteCalendarObject($sourceItem, ?int $expectedBoardId = null, ?int $expectedStackId = null): void { + if ($sourceItem instanceof Card) { + $currentCard = $sourceItem; + if ($expectedBoardId !== null) { + try { + $currentCard = $this->cardService->find($sourceItem->getId()); + $currentBoardId = $this->getBoardIdForCard($currentCard); + if ($currentBoardId !== $expectedBoardId) { + // Ignore trailing delete from source calendar after a cross-board move. + return; + } + if ($expectedStackId !== null && $currentCard->getStackId() !== $expectedStackId) { + // Ignore trailing delete from source list calendar after an in-board move. + return; + } + } catch (\Throwable $e) { + // If we cannot resolve the current card, continue with normal delete behavior. + } + } + + $this->cardService->delete($sourceItem->getId()); + return; + } + + if ($sourceItem instanceof Stack) { + $this->stackService->delete($sourceItem->getId()); + return; + } + + throw new \InvalidArgumentException('Unsupported calendar object source item'); + } + + private function updateCardFromCalendar(Card $sourceItem, string $data, bool $restoreDeleted = false, ?int $targetBoardId = null): Card { + $todo = $this->extractTodo($data); + $card = $restoreDeleted ? $sourceItem : $this->cardService->find($sourceItem->getId()); + $currentBoardId = $this->getBoardIdForCard($card); + $boardId = $targetBoardId ?? $currentBoardId; + + $title = trim((string)($todo->SUMMARY ?? '')); + if ($title === '') { + $title = $card->getTitle(); + } + + $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription(); + $mode = $this->configService->getCalDavListMode(); + $relatedStackId = $this->extractStackIdFromRelatedTo($todo); + if ($relatedStackId === null) { + $relatedStackId = $this->inferStackIdFromTodoHints($boardId, $todo, $mode); + } + if ($relatedStackId !== null) { + $stackId = $this->resolveStackIdForBoard($boardId, $relatedStackId); + } elseif ($targetBoardId !== null && $currentBoardId !== $targetBoardId) { + $stackId = $this->getDefaultStackIdForBoard($targetBoardId); + } else { + $stackId = $card->getStackId(); + } + $done = $this->mapDoneFromTodo($todo, $card); + $incomingDue = isset($todo->DUE) ? $todo->DUE->getDateTime() : null; + + $isNoopUpdate = $title === $card->getTitle() + && $stackId === $card->getStackId() + && $this->normalizeDescriptionForCompare($description) === $this->normalizeDescriptionForCompare((string)$card->getDescription()) + && $this->isDateEqual($card->getDuedate(), $incomingDue) + && $this->isDateEqual($card->getDone(), $done->getValue()) + && (!$restoreDeleted || $card->getDeletedAt() === 0); + + if ($isNoopUpdate) { + return $card; + } + + $updatedCard = $this->cardService->update( + $card->getId(), + $title, + $stackId, + $card->getType(), + $card->getOwner() ?? '', + $description, + $card->getOrder(), + $incomingDue ? $incomingDue->format('c') : null, + $restoreDeleted ? 0 : $card->getDeletedAt(), + $card->getArchived(), + $done + ); + $categories = $this->extractCategories($todo); + if ($categories !== null) { + $categories = $this->normalizeCategoriesForLabelSync($boardId, $categories, $mode); + $this->syncCardCategories($updatedCard->getId(), $categories); + } + + return $updatedCard; + } + + /** + * @param Card|Stack $sourceItem + */ + public function decorateCalendarObject($sourceItem, $calendarObject): void { + if (!($sourceItem instanceof Card) && !($sourceItem instanceof Stack)) { + return; + } + + $todos = $calendarObject->select('VTODO'); + if (count($todos) === 0 || !$this->isSabreVTodo($todos[0])) { + return; + } + + $todo = $todos[0]; + $mode = $this->configService->getCalDavListMode(); + if ($sourceItem instanceof Card) { + $stack = $this->stackService->find($sourceItem->getStackId()); + } else { + $stack = $sourceItem; + } + + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY) { + $this->addTodoCategory($todo, $stack->getTitle()); + } + + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY) { + $priority = $this->calculateStackPriority($stack->getBoardId(), $stack->getId()); + $todo->PRIORITY = $priority; + } + } + + private function updateStackFromCalendar(Stack $sourceItem, string $data): Stack { + $todo = $this->extractTodo($data); + $stack = $this->stackService->find($sourceItem->getId()); + + $title = trim((string)($todo->SUMMARY ?? '')); + if (mb_strpos($title, 'List : ') === 0) { + $title = mb_substr($title, mb_strlen('List : ')); + } + if ($title === '') { + $title = $stack->getTitle(); + } + + return $this->stackService->update( + $stack->getId(), + $title, + $stack->getBoardId(), + $stack->getOrder(), + $stack->getDeletedAt() + ); + } + + private function extractTodo(string $data) { + /** @psalm-suppress UndefinedClass */ + $vObject = \Sabre\VObject\Reader::read($data); + if (!$this->isSabreVCalendar($vObject)) { + throw new \InvalidArgumentException('Invalid calendar payload'); + } + + $todos = $vObject->select('VTODO'); + if (count($todos) === 0 || !$this->isSabreVTodo($todos[0])) { + throw new \InvalidArgumentException('Calendar payload contains no VTODO'); + } + return $todos[0]; + } + + private function extractStackIdFromRelatedTo($todo): ?int { + $parentCandidates = []; + $otherCandidates = []; + foreach ($todo->children() as $child) { + if (!is_object($child) || !property_exists($child, 'name') || $child->name !== 'RELATED-TO') { + continue; + } + + $value = trim($this->toStringValue($child)); + if ($value === '') { + continue; + } + + $reltypeValue = $this->getPropertyParameter($child, 'RELTYPE'); + $reltype = $reltypeValue !== null ? strtoupper($reltypeValue) : null; + if ($reltype === 'PARENT') { + $parentCandidates[] = $value; + } else { + $otherCandidates[] = $value; + } + } + + foreach (array_merge($parentCandidates, $otherCandidates) as $candidate) { + if (preg_match('/^deck-stack-(\d+)$/', $candidate, $matches) === 1) { + return (int)$matches[1]; + } + } + + return null; + } + + private function mapDoneFromTodo($todo, Card $card): OptionalNullableValue { + $done = $card->getDone(); + $percentComplete = isset($todo->{'PERCENT-COMPLETE'}) ? (int)((string)$todo->{'PERCENT-COMPLETE'}) : null; + if (!isset($todo->STATUS) && !isset($todo->COMPLETED) && $percentComplete === null) { + return new OptionalNullableValue($done); + } + + $status = isset($todo->STATUS) ? strtoupper((string)$todo->STATUS) : null; + if ($status === 'COMPLETED' || isset($todo->COMPLETED) || ($percentComplete !== null && $percentComplete >= 100)) { + if (isset($todo->COMPLETED)) { + $completed = $todo->COMPLETED->getDateTime(); + $done = new \DateTime($completed->format('c')); + } else { + $done = new \DateTime(); + } + } elseif ($status === 'NEEDS-ACTION' || $status === 'IN-PROCESS' || ($percentComplete !== null && $percentComplete === 0)) { + $done = null; + } + + return new OptionalNullableValue($done); + } + + private function inferStackIdFromTodoHints(int $boardId, $todo, string $mode): ?int { + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY) { + $categories = $this->extractCategories($todo) ?? []; + return $this->inferStackIdFromCategories($boardId, $categories); + } + + if ($mode === ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY) { + $priority = isset($todo->PRIORITY) ? (int)((string)$todo->PRIORITY) : null; + return $this->inferStackIdFromPriority($boardId, $priority); + } + + return null; + } + + /** + * @param list $categories + * @return list + */ + private function normalizeCategoriesForLabelSync(int $boardId, array $categories, string $mode): array { + if ($mode !== ConfigService::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY) { + return $categories; + } + + $stacks = $this->stackService->findAll($boardId); + $stackTitles = []; + foreach ($stacks as $stack) { + $key = mb_strtolower(trim($stack->getTitle())); + if ($key !== '') { + $stackTitles[$key] = true; + } + } + + return array_values(array_filter($categories, static function (string $category) use ($stackTitles): bool { + $key = mb_strtolower(trim($category)); + return $key !== '' && !isset($stackTitles[$key]); + })); + } + + private function inferStackIdFromCategories(int $boardId, array $categories): ?int { + if (count($categories) === 0) { + return null; + } + + $categoriesByKey = []; + foreach ($categories as $category) { + $key = mb_strtolower(trim($category)); + if ($key !== '') { + $categoriesByKey[$key] = true; + } + } + + foreach ($this->stackService->findAll($boardId) as $stack) { + $key = mb_strtolower(trim($stack->getTitle())); + if ($key !== '' && isset($categoriesByKey[$key])) { + return $stack->getId(); + } + } + + return null; + } + + private function inferStackIdFromPriority(int $boardId, ?int $priority): ?int { + if ($priority === null || $priority < 1 || $priority > 9) { + return null; + } + + $stacks = $this->stackService->findAll($boardId); + if (count($stacks) === 0) { + return null; + } + usort($stacks, static fn (Stack $a, Stack $b) => $a->getOrder() <=> $b->getOrder()); + + // Priority mapping for list mode: left-most list = high priority (9), right-most list = low priority (1) + $targetIndex = (int)round((9 - $priority) * (count($stacks) - 1) / 8); + return $stacks[max(0, min(count($stacks) - 1, $targetIndex))]->getId(); + } + + private function calculateStackPriority(int $boardId, int $stackId): int { + $stacks = $this->stackService->findAll($boardId); + if (count($stacks) <= 1) { + return 1; + } + + usort($stacks, static fn (Stack $a, Stack $b) => $a->getOrder() <=> $b->getOrder()); + $index = 0; + foreach ($stacks as $position => $stack) { + if ($stack->getId() === $stackId) { + $index = $position; + break; + } + } + + // Priority mapping for list mode: left-most list = high priority (9), right-most list = low priority (1) + return max(1, min(9, 9 - (int)round($index * 8 / (count($stacks) - 1)))); + } + + private function addTodoCategory($todo, string $category): void { + $category = trim($category); + if ($category === '') { + return; + } + + $current = []; + foreach ($todo->select('CATEGORIES') as $property) { + if (is_object($property) && method_exists($property, 'getParts')) { + foreach ($property->getParts() as $part) { + $key = mb_strtolower(trim((string)$part)); + if ($key !== '') { + $current[$key] = trim((string)$part); + } + } + } + } + + $key = mb_strtolower($category); + if (!isset($current[$key])) { + $current[$key] = $category; + $todo->CATEGORIES = array_values($current); + } + } + + /** + * @return list|null + */ + private function extractCategories($todo): ?array { + $properties = $todo->select('CATEGORIES'); + foreach ($todo->children() as $child) { + if (is_object($child) + && property_exists($child, 'name') + && strtoupper((string)$child->name) === 'X-APPLE-TAGS') { + $properties[] = $child; + } + } + if (count($properties) === 0) { + return null; + } + + $values = []; + foreach ($properties as $property) { + if (is_object($property) && method_exists($property, 'getParts')) { + $parts = $property->getParts(); + } else { + $parts = explode(',', $this->toStringValue($property)); + } + foreach ($parts as $part) { + $title = trim((string)$part); + $title = ltrim($title, '#'); + if ($title !== '') { + $values[$title] = true; + } + } + } + + return array_keys($values); + } + + /** + * @param list $categories + */ + private function syncCardCategories(int $cardId, array $categories): void { + $card = $this->cardService->find($cardId); + $boardId = $this->getBoardIdForCard($card); + $board = $this->boardMapper->find($boardId, true, false); + $boardLabels = $board->getLabels() ?? []; + + $boardLabelsByTitle = []; + foreach ($boardLabels as $label) { + $key = mb_strtolower(trim($label->getTitle())); + if ($key !== '' && !isset($boardLabelsByTitle[$key])) { + $boardLabelsByTitle[$key] = $label; + } + } + + $targetLabelIds = []; + foreach ($categories as $category) { + $title = trim($category); + $key = mb_strtolower($title); + if ($key === '' || !isset($boardLabelsByTitle[$key])) { + $createdLabel = $this->createLabelForCategory($boardId, $title); + if ($createdLabel !== null) { + $boardLabelsByTitle[$key] = $createdLabel; + } + } + if (!isset($boardLabelsByTitle[$key])) { + continue; + } + $targetLabelIds[$boardLabelsByTitle[$key]->getId()] = true; + } + + $currentLabels = $card->getLabels() ?? []; + $currentLabelIds = []; + foreach ($currentLabels as $label) { + $currentLabelIds[$label->getId()] = true; + } + + foreach (array_keys($currentLabelIds) as $labelId) { + if (!isset($targetLabelIds[$labelId])) { + $this->cardService->removeLabel($cardId, $labelId); + } + } + + foreach (array_keys($targetLabelIds) as $labelId) { + if (!isset($currentLabelIds[$labelId])) { + $this->cardService->assignLabel($cardId, $labelId); + } + } + } + + private function createLabelForCategory(int $boardId, string $title): ?\OCA\Deck\Db\Label { + $title = trim($title); + if ($title === '') { + return null; + } + + try { + return $this->labelService->create($title, '31CC7C', $boardId); + } catch (\Throwable $e) { + try { + \OCP\Server::get(LoggerInterface::class)->debug('[deck-caldav] label-create-failed', [ + 'boardId' => $boardId, + 'title' => $title, + 'error' => $e->getMessage(), + ]); + } catch (\Throwable $ignored) { + } + try { + $board = $this->boardMapper->find($boardId, true, false); + foreach ($board->getLabels() ?? [] as $label) { + if (mb_strtolower(trim($label->getTitle())) === mb_strtolower($title)) { + return $label; + } + } + } catch (\Throwable $ignored) { + } + } + + return null; + } + + private function getDefaultStackIdForBoard(int $boardId): int { + $stacks = $this->stackService->findAll($boardId); + if (count($stacks) === 0) { + throw new \InvalidArgumentException('No stack available for board'); + } + + usort($stacks, static fn (Stack $a, Stack $b) => $a->getOrder() <=> $b->getOrder()); + return $stacks[0]->getId(); + } + + private function resolveStackIdForBoard(int $boardId, ?int $candidateStackId): int { + if ($candidateStackId === null) { + return $this->getDefaultStackIdForBoard($boardId); + } + + try { + $stack = $this->stackService->find($candidateStackId); + if ($stack->getBoardId() === $boardId) { + return $candidateStackId; + } + } catch (\Throwable $e) { + // Fall through to default stack if referenced stack is inaccessible or does not exist. + } + + return $this->getDefaultStackIdForBoard($boardId); + } + + private function getBoardIdForCard(Card $card): int { + $stack = $this->stackService->find($card->getStackId()); + return $stack->getBoardId(); + } + + private function getBoardIdForCardOrNull(Card $card): ?int { + try { + return $this->getBoardIdForCard($card); + } catch (\Throwable $e) { + return null; + } + } + + private function findExistingCardByUid($todo): ?Card { + $cardIdFromDeckProperty = $this->extractDeckCardId($todo); + if ($cardIdFromDeckProperty !== null) { + return $this->findCardByIdIncludingDeleted($cardIdFromDeckProperty); + } + + if (!isset($todo->UID)) { + return null; + } + + $uid = trim((string)$todo->UID); + if (preg_match('/^deck-card-(\d+)$/', $uid, $matches) !== 1) { + return null; + } + + $cardId = (int)$matches[1]; + return $this->findCardByIdIncludingDeleted($cardId); + } + + private function extractDeckCardId($todo): ?int { + $propertyNames = [ + 'X-NC-DECK-CARD-ID', + 'X-NEXTCLOUD-DECK-CARD-ID', + ]; + + foreach ($propertyNames as $propertyName) { + if (!isset($todo->{$propertyName})) { + continue; + } + + $value = trim((string)$todo->{$propertyName}); + if (preg_match('/^\d+$/', $value) === 1) { + return (int)$value; + } + } + + return null; + } + + private function findCardByIdIncludingDeleted(int $cardId): ?Card { + try { + return $this->cardService->findIncludingDeletedLite($cardId); + } catch (\Throwable $e) { + return null; + } + } + + private function normalizeDescriptionForCompare(string $value): string { + return str_replace(["\r\n", "\r"], "\n", $value); + } + + private function isDateEqual(?\DateTimeInterface $left, ?\DateTimeInterface $right): bool { + if ($left === null && $right === null) { + return true; + } + if ($left === null || $right === null) { + return false; + } + + return $left->getTimestamp() === $right->getTimestamp(); + } + + private function isSabreVCalendar($value): bool { + /** @psalm-suppress UndefinedClass */ + return $value instanceof VCalendar; + } + + private function isSabreVTodo($value): bool { + /** @psalm-suppress UndefinedClass */ + return $value instanceof VTodo; + } + + private function getPropertyParameter($property, string $parameter): ?string { + if (!is_object($property) || !($property instanceof \ArrayAccess) || !isset($property[$parameter])) { + return null; + } + + $value = $property[$parameter]; + $string = trim($this->toStringValue($value)); + return $string !== '' ? $string : null; + } + + private function toStringValue($value): string { + if (is_scalar($value)) { + return (string)$value; + } + if (is_object($value) && method_exists($value, '__toString')) { + return (string)$value; + } + + return ''; + } + } diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index ebf4a672e1..5739344228 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -286,13 +286,13 @@ public function findAllByGroups(string $userId, array $groups, ?int $limit = nul ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) ->where($qb->expr()->eq('acl.type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_GROUP, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->neq('b.owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))); - $or = $qb->expr()->orx(); + $orConditions = []; for ($i = 0, $iMax = count($groups); $i < $iMax; $i++) { - $or->add( - $qb->expr()->eq('acl.participant', $qb->createNamedParameter($groups[$i], IQueryBuilder::PARAM_STR)) - ); + $orConditions[] + = $qb->expr()->eq('acl.participant', $qb->createNamedParameter($groups[$i], IQueryBuilder::PARAM_STR)) + ; } - $qb->andWhere($or); + $qb->andWhere($qb->expr()->orX(...$orConditions)); if (!$includeArchived) { $qb->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); @@ -342,13 +342,13 @@ public function findAllByCircles(string $userId, ?int $limit = null, ?int $offse ->innerJoin('b', 'deck_board_acl', 'acl', $qb->expr()->eq('b.id', 'acl.board_id')) ->where($qb->expr()->eq('acl.type', $qb->createNamedParameter(Acl::PERMISSION_TYPE_CIRCLE, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->neq('b.owner', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))); - $or = $qb->expr()->orx(); + $orConditions = []; for ($i = 0, $iMax = count($circles); $i < $iMax; $i++) { - $or->add( - $qb->expr()->eq('acl.participant', $qb->createNamedParameter($circles[$i], IQueryBuilder::PARAM_STR)) - ); + $orConditions[] + = $qb->expr()->eq('acl.participant', $qb->createNamedParameter($circles[$i], IQueryBuilder::PARAM_STR)) + ; } - $qb->andWhere($or); + $qb->andWhere($qb->expr()->orX(...$orConditions)); if (!$includeArchived) { $qb->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); diff --git a/lib/Db/Card.php b/lib/Db/Card.php index cd22f1330e..e322e2109a 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -127,30 +127,44 @@ public function getCalendarObject(): VCalendar { $calendar = new VCalendar(); $event = $calendar->createComponent('VTODO'); $event->UID = 'deck-card-' . $this->getId(); + $event->{'X-NC-DECK-CARD-ID'} = (string)$this->getId(); + $createdAtTs = $this->getCreatedAt() > 0 ? $this->getCreatedAt() : time(); + $lastModifiedTs = $this->getLastModified() > 0 ? $this->getLastModified() : $createdAtTs; + $createdAt = new DateTime(); + $createdAt->setTimestamp($createdAtTs); + $lastModified = new DateTime(); + $lastModified->setTimestamp($lastModifiedTs); + $event->DTSTAMP = $lastModified; + $event->CREATED = $createdAt; + $event->{'LAST-MODIFIED'} = $lastModified; if ($this->getDuedate()) { - $creationDate = new DateTime(); - $creationDate->setTimestamp($this->createdAt); - $event->DTSTAMP = $creationDate; $event->DUE = new DateTime($this->getDuedate()->format('c'), new DateTimeZone('UTC')); } - $event->add('RELATED-TO', 'deck-stack-' . $this->getStackId()); + $event->add('RELATED-TO', 'deck-stack-' . $this->getStackId(), ['RELTYPE' => 'PARENT']); // FIXME: For write support: CANCELLED / IN-PROCESS handling if ($this->getDone() || $this->getArchived()) { - $date = new DateTime(); - $date->setTimestamp($this->getLastModified()); $event->STATUS = 'COMPLETED'; - $event->COMPLETED = $this->getDone() ? $this->getDone() : $this->getArchived(); + $event->{'PERCENT-COMPLETE'} = 100; + if ($this->getDone()) { + $event->COMPLETED = $this->getDone(); + } else { + $event->COMPLETED = $lastModified; + } } else { $event->STATUS = 'NEEDS-ACTION'; + $event->{'PERCENT-COMPLETE'} = 0; } - // $event->add('PERCENT-COMPLETE', 100); - $labels = $this->getLabels() ?? []; - $event->CATEGORIES = array_map(function ($label): string { + $categoryTitles = array_map(function ($label): string { return $label->getTitle(); }, $labels); + $event->CATEGORIES = $categoryTitles; + if (count($categoryTitles) > 0) { + // Apple Reminders uses this non-standard property for tags on CalDAV tasks. + $event->{'X-APPLE-TAGS'} = implode(',', $categoryTitles); + } $event->SUMMARY = $this->getTitle(); $event->DESCRIPTION = $this->getDescription(); diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index f47bea2d95..8d22dd8b15 100644 --- a/lib/Db/CardMapper.php +++ b/lib/Db/CardMapper.php @@ -134,7 +134,8 @@ public function find($id, bool $enhance = true): Card { * @return Card[] * @throws \OCP\DB\Exception */ - public function findAll($stackId, ?int $limit = null, int $offset = 0, int $since = -1) { + public function findAll($stackId, ?int $limit = null, ?int $offset = 0, int $since = -1) { + $offset ??= 0; $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from('deck_cards') @@ -154,7 +155,8 @@ public function findAll($stackId, ?int $limit = null, int $offset = 0, int $sinc * @return array * @throws \OCP\DB\Exception */ - public function findAllForStacks(array $stackIds, ?int $limit = null, int $offset = 0, int $since = -1): array { + public function findAllForStacks(array $stackIds, ?int $limit = null, ?int $offset = 0, int $since = -1): array { + $offset ??= 0; $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from('deck_cards') @@ -234,7 +236,12 @@ public function findCalendarEntries(int $boardId, ?int $limit = null, $offset = ->orderBy('c.duedate') ->setMaxResults($limit) ->setFirstResult($offset); - return $this->findEntities($qb); + $cards = $this->findEntities($qb); + foreach ($cards as $card) { + $labels = $this->labelMapper->findAssignedLabelsForCard($card->getId()); + $card->setLabels($labels); + } + return $cards; } public function findAllArchived($stackId, $limit = null, $offset = null) { diff --git a/lib/Db/Stack.php b/lib/Db/Stack.php index 482e72d34b..4215ff9c7f 100644 --- a/lib/Db/Stack.php +++ b/lib/Db/Stack.php @@ -7,6 +7,7 @@ namespace OCA\Deck\Db; +use DateTime; use Sabre\VObject\Component\VCalendar; /** @@ -56,6 +57,12 @@ public function getCalendarObject(): VCalendar { $event = $calendar->createComponent('VTODO'); $event->UID = 'deck-stack-' . $this->getId(); $event->SUMMARY = 'List : ' . $this->getTitle(); + $lastModifiedTs = $this->getLastModified() > 0 ? $this->getLastModified() : time(); + $lastModified = new DateTime(); + $lastModified->setTimestamp($lastModifiedTs); + $event->DTSTAMP = $lastModified; + $event->{'LAST-MODIFIED'} = $lastModified; + $event->STATUS = 'NEEDS-ACTION'; $calendar->add($event); return $calendar; } diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index d8ca9adc00..05daa557a5 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -156,6 +156,44 @@ public function find(int $cardId): Card { return $card; } + /** + * Find a card by id including soft-deleted entries. + * + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function findIncludingDeleted(int $cardId): Card { + // Keep this call compatible with older PermissionService signatures. + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ, null, true); + $card = $this->cardMapper->find($cardId); + [$card] = $this->enrichCards([$card]); + + $attachments = $this->attachmentService->findAll($cardId, true); + if ($this->request->getParam('apiVersion') === '1.0') { + $attachments = array_filter($attachments, function ($attachment) { + return $attachment->getType() === 'deck_file'; + }); + } + $card->setAttachments($attachments); + + return $card; + } + + /** + * Lightweight variant for internal CalDAV lookups where enriched relations + * and attachments are not required. + * + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function findIncludingDeletedLite(int $cardId): Card { + // Keep this call compatible with older PermissionService signatures. + $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_READ, null, true); + return $this->cardMapper->find($cardId); + } + /** * @return Card[] */ @@ -312,6 +350,9 @@ public function update(int $id, string $title, int $stackId, string $type, strin $oldBoardId = $this->stackMapper->findBoardId($changes->getBefore()->getStackId()); $boardId = $this->cardMapper->findBoardId($card->getId()); if ($boardId !== $oldBoardId) { + if ($oldBoardId !== null) { + $this->changeHelper->boardChanged($oldBoardId); + } $stack = $this->stackMapper->find($card->getStackId()); $board = $this->boardService->find($this->cardMapper->findBoardId($card->getId())); $boardLabels = $board->getLabels() ?? []; diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 7b8b031ccc..a2eac376dc 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -23,6 +23,11 @@ class ConfigService { public const SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED = 'assigned'; public const SETTING_BOARD_NOTIFICATION_DUE_ALL = 'all'; public const SETTING_BOARD_NOTIFICATION_DUE_DEFAULT = self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED; + public const SETTING_CALDAV_LIST_MODE_ROOT_TASKS = 'root_tasks'; + public const SETTING_CALDAV_LIST_MODE_PER_LIST_CALENDAR = 'per_list_calendar'; + public const SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY = 'list_as_category'; + public const SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY = 'list_as_priority'; + public const SETTING_CALDAV_LIST_MODE_DEFAULT = self::SETTING_CALDAV_LIST_MODE_ROOT_TASKS; private IConfig $config; private ?string $userId = null; @@ -56,7 +61,8 @@ public function getAll(): array { $data = [ 'calendar' => $this->isCalendarEnabled(), 'cardDetailsInModal' => $this->isCardDetailsInModal(), - 'cardIdBadge' => $this->isCardIdBadgeEnabled() + 'cardIdBadge' => $this->isCardIdBadgeEnabled(), + 'caldavListMode' => $this->getCalDavListMode(), ]; if ($this->groupManager->isAdmin($userId)) { $data['groupLimit'] = $this->get('groupLimit'); @@ -65,7 +71,7 @@ public function getAll(): array { } /** - * @return bool|array{id: string, displayname: string}[] + * @return bool|string|array{id: string, displayname: string}[] * @throws NoPermissionException */ public function get(string $key) { @@ -91,10 +97,29 @@ public function get(string $key) { return false; } return (bool)$this->config->getUserValue($this->getUserId(), Application::APP_ID, 'cardIdBadge', false); + case 'caldavListMode': + return $this->getCalDavListMode(); } return false; } + public function getCalDavListMode(): string { + $userId = $this->getUserId(); + if ($userId === null) { + return self::SETTING_CALDAV_LIST_MODE_DEFAULT; + } + + $value = (string)$this->config->getUserValue($userId, Application::APP_ID, 'caldavListMode', self::SETTING_CALDAV_LIST_MODE_DEFAULT); + $allowed = [ + self::SETTING_CALDAV_LIST_MODE_ROOT_TASKS, + self::SETTING_CALDAV_LIST_MODE_PER_LIST_CALENDAR, + self::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY, + self::SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY, + ]; + + return in_array($value, $allowed, true) ? $value : self::SETTING_CALDAV_LIST_MODE_DEFAULT; + } + public function isCalendarEnabled(?int $boardId = null): bool { $userId = $this->getUserId(); if ($userId === null) { @@ -162,6 +187,19 @@ public function set($key, $value) { $this->config->setUserValue($userId, Application::APP_ID, 'cardIdBadge', (string)$value); $result = $value; break; + case 'caldavListMode': + $allowed = [ + self::SETTING_CALDAV_LIST_MODE_ROOT_TASKS, + self::SETTING_CALDAV_LIST_MODE_PER_LIST_CALENDAR, + self::SETTING_CALDAV_LIST_MODE_LIST_AS_CATEGORY, + self::SETTING_CALDAV_LIST_MODE_LIST_AS_PRIORITY, + ]; + if (!in_array((string)$value, $allowed, true)) { + throw new BadRequestException('Unsupported CalDAV list mode'); + } + $this->config->setUserValue($userId, Application::APP_ID, 'caldavListMode', (string)$value); + $result = (string)$value; + break; case 'board': [$boardId, $boardConfigKey] = explode(':', $key); if ($boardConfigKey === 'notify-due' && !in_array($value, [self::SETTING_BOARD_NOTIFICATION_DUE_ALL, self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED, self::SETTING_BOARD_NOTIFICATION_DUE_OFF], true)) { diff --git a/src/components/DeckAppSettings.vue b/src/components/DeckAppSettings.vue index fca5aacd96..fee599460c 100644 --- a/src/components/DeckAppSettings.vue +++ b/src/components/DeckAppSettings.vue @@ -22,6 +22,12 @@ + @@ -63,6 +69,7 @@