From 905cb59fc7417132adfa1f6b5847268e35b9d2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Mon, 16 Feb 2026 23:36:03 +0100 Subject: [PATCH 01/36] feat(deck-dav): enable CalDAV write updates for existing Deck items Implement first working write path for Deck CalDAV objects. - allow DAV write privilege on Deck calendars - handle PUT on existing card/stack ICS objects and map VTODO fields to Deck services - add NC32 compatibility fixes in BoardMapper for empty orX() usage - add unit tests for calendar update mapping Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/Calendar.php | 11 +- lib/DAV/CalendarObject.php | 3 +- lib/DAV/DeckCalendarBackend.php | 114 +++++++++++++++++ lib/Db/BoardMapper.php | 16 +-- tests/unit/DAV/DeckCalendarBackendTest.php | 138 +++++++++++++++++++++ 5 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 tests/unit/DAV/DeckCalendarBackendTest.php diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php index dbc90f9fae..ce6e247853 100644 --- a/lib/DAV/Calendar.php +++ b/lib/DAV/Calendar.php @@ -42,8 +42,12 @@ 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 + // the calendar should always have read and write permissions // write-properties is needed to allow the user to toggle the visibility of shared deck calendars $acl = [ [ @@ -51,6 +55,11 @@ public function getACL() { 'principal' => $this->getOwner(), 'protected' => true, ], + [ + 'privilege' => '{DAV:}write', + 'principal' => $this->getOwner(), + 'protected' => true, + ], [ 'privilege' => '{DAV:}write-properties', 'principal' => $this->getOwner(), diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php index 4b8f6476cd..1ad7e0977d 100644 --- a/lib/DAV/CalendarObject.php +++ b/lib/DAV/CalendarObject.php @@ -55,7 +55,8 @@ 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(); } public function get() { diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 73146b0dda..1397b09778 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -10,13 +10,20 @@ namespace OCA\Deck\DAV; +use OCA\Deck\Db\Card; use OCA\Deck\Db\Board; use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Stack; +use OCA\Deck\Model\OptionalNullableValue; 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; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTodo; +use Sabre\VObject\InvalidDataException; +use Sabre\VObject\Reader; class DeckCalendarBackend { @@ -70,4 +77,111 @@ public function getChildren(int $id): array { $this->stackService->findCalendarEntries($id) ); } + + /** + * @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 InvalidDataException('Unsupported calendar object source item'); + } + + private function updateCardFromCalendar(Card $sourceItem, string $data): Card { + $todo = $this->extractTodo($data); + $card = $this->cardService->find($sourceItem->getId()); + + $title = trim((string)($todo->SUMMARY ?? '')); + if ($title === '') { + $title = $card->getTitle(); + } + + $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription(); + $stackId = $this->extractStackIdFromRelatedTo($todo) ?? $card->getStackId(); + $done = $this->mapDoneFromTodo($todo, $card); + + return $this->cardService->update( + $card->getId(), + $title, + $stackId, + $card->getType(), + $card->getOwner() ?? '', + $description, + $card->getOrder(), + isset($todo->DUE) ? $todo->DUE->getDateTime()->format('c') : null, + $card->getDeletedAt(), + $card->getArchived(), + $done + ); + } + + 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, 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): VTodo { + $vObject = Reader::read($data); + if (!($vObject instanceof VCalendar)) { + throw new InvalidDataException('Invalid calendar payload'); + } + + $todos = $vObject->select('VTODO'); + if (count($todos) === 0 || !($todos[0] instanceof VTodo)) { + throw new InvalidDataException('Calendar payload contains no VTODO'); + } + return $todos[0]; + } + + private function extractStackIdFromRelatedTo(VTodo $todo): ?int { + if (!isset($todo->{'RELATED-TO'})) { + return null; + } + + $relatedTo = trim((string)$todo->{'RELATED-TO'}); + if (preg_match('/^deck-stack-(\d+)$/', $relatedTo, $matches) === 1) { + return (int)$matches[1]; + } + + return null; + } + + private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue { + $done = $card->getDone(); + if (!isset($todo->STATUS)) { + return new OptionalNullableValue($done); + } + + $status = strtoupper((string)$todo->STATUS); + if ($status === 'COMPLETED') { + $done = isset($todo->COMPLETED) ? $todo->COMPLETED->getDateTime() : new \DateTimeImmutable(); + } elseif ($status === 'NEEDS-ACTION' || $status === 'IN-PROCESS') { + $done = null; + } + + return new OptionalNullableValue($done); + } } diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index ebf4a672e1..fe98a9ab61 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( + $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( + $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/tests/unit/DAV/DeckCalendarBackendTest.php b/tests/unit/DAV/DeckCalendarBackendTest.php new file mode 100644 index 0000000000..589711a533 --- /dev/null +++ b/tests/unit/DAV/DeckCalendarBackendTest.php @@ -0,0 +1,138 @@ +createMock(BoardService::class); + $this->stackService = $this->createMock(StackService::class); + $this->cardService = $this->createMock(CardService::class); + $permissionService = $this->createMock(PermissionService::class); + $boardMapper = $this->createMock(BoardMapper::class); + + $this->backend = new DeckCalendarBackend( + $boardService, + $this->stackService, + $this->cardService, + $permissionService, + $boardMapper + ); + } + + public function testUpdateCardFromCalendarData(): void { + $sourceCard = new Card(); + $sourceCard->setId(123); + + $existingCard = new Card(); + $existingCard->setId(123); + $existingCard->setTitle('Old title'); + $existingCard->setDescription('Old description'); + $existingCard->setStackId(42); + $existingCard->setType('plain'); + $existingCard->setOrder(5); + $existingCard->setOwner('admin'); + $existingCard->setDeletedAt(0); + $existingCard->setArchived(false); + $existingCard->setDone(null); + + $this->cardService->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($existingCard); + + $this->cardService->expects($this->once()) + ->method('update') + ->with( + 123, + 'Updated card', + 88, + 'plain', + 'admin', + 'Updated description', + 5, + '2026-03-02T08:00:00+00:00', + 0, + false, + $this->callback(function ($value) { + if (!($value instanceof OptionalNullableValue)) { + return false; + } + $done = $value->getValue(); + return $done instanceof \DateTimeInterface && $done->format('c') === '2026-03-01T10:00:00+00:00'; + }) + ) + ->willReturn($existingCard); + + $calendarData = <<backend->updateCalendarObject($sourceCard, $calendarData); + } + + public function testUpdateStackFromCalendarData(): void { + $sourceStack = new Stack(); + $sourceStack->setId(77); + + $stack = new Stack(); + $stack->setId(77); + $stack->setTitle('Old list'); + $stack->setBoardId(12); + $stack->setOrder(3); + $stack->setDeletedAt(0); + + $this->stackService->expects($this->once()) + ->method('find') + ->with(77) + ->willReturn($stack); + + $this->stackService->expects($this->once()) + ->method('update') + ->with(77, 'Updated list', 12, 3, 0) + ->willReturn($stack); + + $calendarData = <<backend->updateCalendarObject($sourceStack, $calendarData); + } +} From d00682f3bc43720feff9a0fcde324b628cee07e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Mon, 16 Feb 2026 23:53:46 +0100 Subject: [PATCH 02/36] fix(deck-dav): stabilize completed and delete sync from CalDAV Follow-up fixes after real-world macOS Reminders tests. - convert COMPLETED timestamps to DateTime expected by Deck entities - provide calendar object owner/group to avoid DELETE scheduling crashes - add backend tests for delete path and COMPLETED-without-STATUS mapping Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/CalendarObject.php | 6 +- lib/DAV/DeckCalendarBackend.php | 30 +++++++- tests/unit/DAV/DeckCalendarBackendTest.php | 87 +++++++++++++++++++++- 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php index 1ad7e0977d..d179ec9257 100644 --- a/lib/DAV/CalendarObject.php +++ b/lib/DAV/CalendarObject.php @@ -35,11 +35,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() { @@ -78,7 +78,7 @@ public function getSize() { } public function delete() { - throw new Forbidden('This calendar-object is read-only'); + $this->backend->deleteCalendarObject($this->sourceItem); } public function getName() { diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 1397b09778..820c830f7e 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -94,6 +94,23 @@ public function updateCalendarObject($sourceItem, string $data) { throw new InvalidDataException('Unsupported calendar object source item'); } + /** + * @param Card|Stack $sourceItem + */ + public function deleteCalendarObject($sourceItem): void { + if ($sourceItem instanceof Card) { + $this->cardService->delete($sourceItem->getId()); + return; + } + + if ($sourceItem instanceof Stack) { + $this->stackService->delete($sourceItem->getId()); + return; + } + + throw new InvalidDataException('Unsupported calendar object source item'); + } + private function updateCardFromCalendar(Card $sourceItem, string $data): Card { $todo = $this->extractTodo($data); $card = $this->cardService->find($sourceItem->getId()); @@ -171,13 +188,18 @@ private function extractStackIdFromRelatedTo(VTodo $todo): ?int { private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue { $done = $card->getDone(); - if (!isset($todo->STATUS)) { + if (!isset($todo->STATUS) && !isset($todo->COMPLETED)) { return new OptionalNullableValue($done); } - $status = strtoupper((string)$todo->STATUS); - if ($status === 'COMPLETED') { - $done = isset($todo->COMPLETED) ? $todo->COMPLETED->getDateTime() : new \DateTimeImmutable(); + $status = isset($todo->STATUS) ? strtoupper((string)$todo->STATUS) : null; + if ($status === 'COMPLETED' || isset($todo->COMPLETED)) { + 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') { $done = null; } diff --git a/tests/unit/DAV/DeckCalendarBackendTest.php b/tests/unit/DAV/DeckCalendarBackendTest.php index 589711a533..bb94f77f27 100644 --- a/tests/unit/DAV/DeckCalendarBackendTest.php +++ b/tests/unit/DAV/DeckCalendarBackendTest.php @@ -79,7 +79,7 @@ public function testUpdateCardFromCalendarData(): void { return false; } $done = $value->getValue(); - return $done instanceof \DateTimeInterface && $done->format('c') === '2026-03-01T10:00:00+00:00'; + return $done instanceof \DateTime && $done->format('c') === '2026-03-01T10:00:00+00:00'; }) ) ->willReturn($existingCard); @@ -135,4 +135,89 @@ public function testUpdateStackFromCalendarData(): void { $this->backend->updateCalendarObject($sourceStack, $calendarData); } + + public function testDeleteCardFromCalendarObject(): void { + $sourceCard = new Card(); + $sourceCard->setId(321); + + $this->cardService->expects($this->once()) + ->method('delete') + ->with(321); + $this->stackService->expects($this->never()) + ->method('delete'); + + $this->backend->deleteCalendarObject($sourceCard); + } + + public function testDeleteStackFromCalendarObject(): void { + $sourceStack = new Stack(); + $sourceStack->setId(654); + + $this->stackService->expects($this->once()) + ->method('delete') + ->with(654); + $this->cardService->expects($this->never()) + ->method('delete'); + + $this->backend->deleteCalendarObject($sourceStack); + } + + public function testUpdateCardWithCompletedWithoutStatusMarksDone(): void { + $sourceCard = new Card(); + $sourceCard->setId(123); + + $existingCard = new Card(); + $existingCard->setId(123); + $existingCard->setTitle('Card'); + $existingCard->setDescription('Description'); + $existingCard->setStackId(42); + $existingCard->setType('plain'); + $existingCard->setOrder(0); + $existingCard->setOwner('admin'); + $existingCard->setDeletedAt(0); + $existingCard->setArchived(false); + $existingCard->setDone(null); + + $this->cardService->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($existingCard); + + $this->cardService->expects($this->once()) + ->method('update') + ->with( + 123, + 'Card', + 42, + 'plain', + 'admin', + 'Description', + 0, + null, + 0, + false, + $this->callback(function ($value) { + if (!($value instanceof OptionalNullableValue)) { + return false; + } + $done = $value->getValue(); + return $done instanceof \DateTime && $done->format('c') === '2026-03-01T10:00:00+00:00'; + }) + ) + ->willReturn($existingCard); + + $calendarData = <<backend->updateCalendarObject($sourceCard, $calendarData); + } } From fcbd2c6a5053920616d5266a6d2a88ae769620a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 00:59:18 +0100 Subject: [PATCH 03/36] feat(deck-dav): harden CalDAV move/create flows and client interoperability Stabilize bidirectional task sync for Apple Reminders and Thunderbird. - implement createFile support with stack resolution and alias normalization - support delete and robust completed mapping (STATUS/COMPLETED/PERCENT-COMPLETE) - add UID/resource-name upsert logic to avoid duplicates on move-back - handle board-to-board moves by updating existing deck-card IDs - export richer VTODO metadata (DTSTAMP/CREATED/LAST-MODIFIED/PERCENT-COMPLETE) - add extensive unit tests for update/create/delete and fallback paths Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/Calendar.php | 42 ++++- lib/DAV/DeckCalendarBackend.php | 175 +++++++++++++++-- lib/Db/Card.php | 26 ++- lib/Db/Stack.php | 7 + tests/unit/DAV/DeckCalendarBackendTest.php | 210 +++++++++++++++++++++ 5 files changed, 438 insertions(+), 22 deletions(-) diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php index ce6e247853..971d58eb09 100644 --- a/lib/DAV/Calendar.php +++ b/lib/DAV/Calendar.php @@ -104,10 +104,25 @@ protected function validateFilterForObject($object, array $filters) { } public function createFile($name, $data = null) { - throw new Forbidden('Creating a new entry is not implemented'); + $normalizedName = $this->normalizeCalendarObjectName($name); + if ($this->childExists($normalizedName)) { + $this->getChild($normalizedName)->put((string)$data); + $this->children = []; + return; + } + + $owner = $this->extractUserIdFromPrincipalUri(); + $this->backend->createCalendarObject( + $this->board->getId(), + $owner, + (string)$data, + $this->extractCardIdFromNormalizedName($normalizedName) + ); + $this->children = []; } public function getChild($name) { + $name = $this->normalizeCalendarObjectName($name); if ($this->childExists($name)) { $card = array_values(array_filter( $this->getBackendChildren(), @@ -151,6 +166,7 @@ private function getBackendChildren() { } public function childExists($name) { + $name = $this->normalizeCalendarObjectName($name); return count(array_filter( $this->getBackendChildren(), function ($card) use (&$name) { @@ -216,4 +232,28 @@ public function getProperties($properties) { '{' . 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 normalizeCalendarObjectName(string $name): string { + if (preg_match('/^deck-(card-\d+\.ics)$/', $name, $matches) === 1) { + return $matches[1]; + } + + return $name; + } + + private function extractCardIdFromNormalizedName(string $name): ?int { + if (preg_match('/^card-(\d+)\.ics$/', $name, $matches) === 1) { + return (int)$matches[1]; + } + + return null; + } } diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 820c830f7e..ed1e376c92 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -23,6 +23,7 @@ use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VTodo; use Sabre\VObject\InvalidDataException; +use Sabre\VObject\Property; use Sabre\VObject\Reader; class DeckCalendarBackend { @@ -78,6 +79,61 @@ public function getChildren(int $id): array { ); } + public function createCalendarObject(int $boardId, string $owner, string $data, ?int $preferredCardId = null): Card { + $todo = $this->extractTodo($data); + $existingCard = $this->findExistingCardByUid($todo); + if ($existingCard !== null) { + $restoreDeleted = $existingCard->getDeletedAt() > 0; + return $this->updateCardFromCalendar($existingCard, $data, $restoreDeleted, $boardId); + } + + if ($preferredCardId !== null) { + $cardById = $this->findCardByIdIncludingDeleted($preferredCardId); + if ($cardById !== null) { + $restoreDeleted = $cardById->getDeletedAt() > 0; + return $this->updateCardFromCalendar($cardById, $data, $restoreDeleted, $boardId); + } + } + + $title = trim((string)($todo->SUMMARY ?? '')); + if ($title === '') { + $title = 'New task'; + } + + $stackId = $this->resolveStackIdForBoard($boardId, $this->extractStackIdFromRelatedTo($todo)); + $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) { + return $card; + } + + return $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) + ); + } + /** * @param Card|Stack $sourceItem * @return Card|Stack @@ -111,9 +167,11 @@ public function deleteCalendarObject($sourceItem): void { throw new InvalidDataException('Unsupported calendar object source item'); } - private function updateCardFromCalendar(Card $sourceItem, string $data): Card { + private function updateCardFromCalendar(Card $sourceItem, string $data, bool $restoreDeleted = false, ?int $targetBoardId = null): Card { $todo = $this->extractTodo($data); - $card = $this->cardService->find($sourceItem->getId()); + $card = $restoreDeleted ? $sourceItem : $this->cardService->find($sourceItem->getId()); + $currentBoardId = $this->getBoardIdForCard($card); + $boardId = $targetBoardId ?? $currentBoardId; $title = trim((string)($todo->SUMMARY ?? '')); if ($title === '') { @@ -121,7 +179,14 @@ private function updateCardFromCalendar(Card $sourceItem, string $data): Card { } $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription(); - $stackId = $this->extractStackIdFromRelatedTo($todo) ?? $card->getStackId(); + $relatedStackId = $this->extractStackIdFromRelatedTo($todo); + 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); return $this->cardService->update( @@ -133,7 +198,7 @@ private function updateCardFromCalendar(Card $sourceItem, string $data): Card { $description, $card->getOrder(), isset($todo->DUE) ? $todo->DUE->getDateTime()->format('c') : null, - $card->getDeletedAt(), + $restoreDeleted ? 0 : $card->getDeletedAt(), $card->getArchived(), $done ); @@ -174,13 +239,30 @@ private function extractTodo(string $data): VTodo { } private function extractStackIdFromRelatedTo(VTodo $todo): ?int { - if (!isset($todo->{'RELATED-TO'})) { - return null; + $parentCandidates = []; + $otherCandidates = []; + foreach ($todo->children() as $child) { + if (!($child instanceof Property) || $child->name !== 'RELATED-TO') { + continue; + } + + $value = trim((string)$child); + if ($value === '') { + continue; + } + + $reltype = isset($child['RELTYPE']) ? strtoupper((string)$child['RELTYPE']) : null; + if ($reltype === 'PARENT') { + $parentCandidates[] = $value; + } else { + $otherCandidates[] = $value; + } } - $relatedTo = trim((string)$todo->{'RELATED-TO'}); - if (preg_match('/^deck-stack-(\d+)$/', $relatedTo, $matches) === 1) { - return (int)$matches[1]; + foreach (array_merge($parentCandidates, $otherCandidates) as $candidate) { + if (preg_match('/^deck-stack-(\d+)$/', $candidate, $matches) === 1) { + return (int)$matches[1]; + } } return null; @@ -188,22 +270,91 @@ private function extractStackIdFromRelatedTo(VTodo $todo): ?int { private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue { $done = $card->getDone(); - if (!isset($todo->STATUS) && !isset($todo->COMPLETED)) { + $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)) { + 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') { + } elseif ($status === 'NEEDS-ACTION' || $status === 'IN-PROCESS' || ($percentComplete !== null && $percentComplete === 0)) { $done = null; } return new OptionalNullableValue($done); } + + private function getDefaultStackIdForBoard(int $boardId): int { + $stacks = $this->stackService->findAll($boardId); + if (count($stacks) === 0) { + throw new InvalidDataException('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 findExistingCardByUid(VTodo $todo): ?Card { + 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 findCardByIdIncludingDeleted(int $cardId): ?Card { + try { + return $this->cardService->find($cardId); + } catch (\Throwable $e) { + // continue with deleted cards + } + + foreach ($this->boardService->findAll(-1, false, false) as $board) { + try { + foreach ($this->cardService->fetchDeleted($board->getId()) as $deletedCard) { + if ($deletedCard->getId() === $cardId) { + return $deletedCard; + } + } + } catch (\Throwable $e) { + // ignore inaccessible board and continue searching + } + } + + return null; + } } diff --git a/lib/Db/Card.php b/lib/Db/Card.php index cd22f1330e..aa2b8aa36c 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -127,26 +127,34 @@ public function getCalendarObject(): VCalendar { $calendar = new VCalendar(); $event = $calendar->createComponent('VTODO'); $event->UID = 'deck-card-' . $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 { return $label->getTitle(); 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/tests/unit/DAV/DeckCalendarBackendTest.php b/tests/unit/DAV/DeckCalendarBackendTest.php index bb94f77f27..764aa9bddf 100644 --- a/tests/unit/DAV/DeckCalendarBackendTest.php +++ b/tests/unit/DAV/DeckCalendarBackendTest.php @@ -60,6 +60,18 @@ public function testUpdateCardFromCalendarData(): void { ->method('find') ->with(123) ->willReturn($existingCard); + $currentStack = new Stack(); + $currentStack->setId(42); + $currentStack->setBoardId(12); + $targetStack = new Stack(); + $targetStack->setId(88); + $targetStack->setBoardId(12); + $this->stackService->expects($this->exactly(2)) + ->method('find') + ->willReturnMap([ + [42, $currentStack], + [88, $targetStack], + ]); $this->cardService->expects($this->once()) ->method('update') @@ -182,6 +194,13 @@ public function testUpdateCardWithCompletedWithoutStatusMarksDone(): void { ->method('find') ->with(123) ->willReturn($existingCard); + $currentStack = new Stack(); + $currentStack->setId(42); + $currentStack->setBoardId(12); + $this->stackService->expects($this->once()) + ->method('find') + ->with(42) + ->willReturn($currentStack); $this->cardService->expects($this->once()) ->method('update') @@ -220,4 +239,195 @@ public function testUpdateCardWithCompletedWithoutStatusMarksDone(): void { $this->backend->updateCalendarObject($sourceCard, $calendarData); } + + public function testCreateCardFromCalendarUsesRelatedStack(): void { + $stack = new Stack(); + $stack->setId(88); + $stack->setBoardId(12); + + $card = new Card(); + $this->stackService->expects($this->once()) + ->method('find') + ->with(88) + ->willReturn($stack); + $this->stackService->expects($this->never()) + ->method('findAll'); + $this->cardService->expects($this->once()) + ->method('create') + ->with( + 'Created task', + 88, + 'plain', + 999, + 'admin', + 'From mac', + $this->callback(fn ($value) => $value instanceof \DateTime && $value->format('c') === '2026-03-03T12:00:00+00:00') + ) + ->willReturn($card); + + $calendarData = <<backend->createCalendarObject(12, 'admin', $calendarData); + } + + public function testCreateCardFromCalendarFallsBackToDefaultStack(): void { + $stackA = new Stack(); + $stackA->setId(5); + $stackA->setOrder(3); + $stackB = new Stack(); + $stackB->setId(7); + $stackB->setOrder(0); + + $card = new Card(); + $this->stackService->expects($this->once()) + ->method('findAll') + ->with(12) + ->willReturn([$stackA, $stackB]); + $this->cardService->expects($this->once()) + ->method('create') + ->with( + 'Created without relation', + 7, + 'plain', + 999, + 'admin', + '', + null + ) + ->willReturn($card); + + $calendarData = <<backend->createCalendarObject(12, 'admin', $calendarData); + } + + public function testCreateCardFromCalendarWithForeignRelatedStackFallsBackToDefaultStack(): void { + $foreignStack = new Stack(); + $foreignStack->setId(99); + $foreignStack->setBoardId(999); + + $stackA = new Stack(); + $stackA->setId(5); + $stackA->setOrder(3); + $stackB = new Stack(); + $stackB->setId(7); + $stackB->setOrder(0); + + $card = new Card(); + $this->stackService->expects($this->once()) + ->method('find') + ->with(99) + ->willReturn($foreignStack); + $this->stackService->expects($this->once()) + ->method('findAll') + ->with(12) + ->willReturn([$stackA, $stackB]); + $this->cardService->expects($this->once()) + ->method('create') + ->with( + 'Foreign related stack', + 7, + 'plain', + 999, + 'admin', + '', + null + ) + ->willReturn($card); + + $calendarData = <<backend->createCalendarObject(12, 'admin', $calendarData); + } + + public function testCreateCardFromCalendarWithExistingDeckUidUpdatesInsteadOfCreating(): void { + $sourceCard = new Card(); + $sourceCard->setId(123); + $sourceCard->setTitle('Old title'); + $sourceCard->setDescription('Old description'); + $sourceCard->setStackId(42); + $sourceCard->setType('plain'); + $sourceCard->setOrder(2); + $sourceCard->setOwner('admin'); + $sourceCard->setDeletedAt(0); + $sourceCard->setArchived(false); + $sourceCard->setDone(null); + + $sourceStack = new Stack(); + $sourceStack->setId(42); + $sourceStack->setBoardId(12); + $targetStack = new Stack(); + $targetStack->setId(88); + $targetStack->setBoardId(12); + + $this->cardService->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($sourceCard); + $this->stackService->expects($this->exactly(2)) + ->method('find') + ->willReturnMap([ + [42, $sourceStack], + [88, $targetStack], + ]); + $this->cardService->expects($this->never()) + ->method('create'); + $this->stackService->expects($this->never()) + ->method('findAll'); + $this->cardService->expects($this->once()) + ->method('update') + ->with( + 123, + 'Updated by uid', + 88, + 'plain', + 'admin', + 'Moved back', + 2, + null, + 0, + false, + $this->isInstanceOf(OptionalNullableValue::class) + ) + ->willReturn($sourceCard); + + $calendarData = <<backend->createCalendarObject(12, 'admin', $calendarData); + } } From 4a60d0ccc9c4c162296d7c052c39823a0c672ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:02:38 +0100 Subject: [PATCH 04/36] feat(caldav): map VTODO CATEGORIES to Deck labels Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/DeckCalendarBackend.php | 116 +++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 16 deletions(-) diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index ed1e376c92..de78d58a6b 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -24,6 +24,7 @@ use Sabre\VObject\Component\VTodo; use Sabre\VObject\InvalidDataException; use Sabre\VObject\Property; +use Sabre\VObject\Property\ICalendar\Categories; use Sabre\VObject\Reader; class DeckCalendarBackend { @@ -115,23 +116,28 @@ public function createCalendarObject(int $boardId, string $owner, string $data, ); $done = $this->mapDoneFromTodo($todo, $card)->getValue(); - if ($done === null) { - return $card; + 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) + ); } - return $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) { + $this->syncCardCategories($card->getId(), $categories); + } + + return $card; } /** @@ -189,7 +195,7 @@ private function updateCardFromCalendar(Card $sourceItem, string $data, bool $re } $done = $this->mapDoneFromTodo($todo, $card); - return $this->cardService->update( + $updatedCard = $this->cardService->update( $card->getId(), $title, $stackId, @@ -202,6 +208,13 @@ private function updateCardFromCalendar(Card $sourceItem, string $data, bool $re $card->getArchived(), $done ); + + $categories = $this->extractCategories($todo); + if ($categories !== null) { + $this->syncCardCategories($updatedCard->getId(), $categories); + } + + return $updatedCard; } private function updateStackFromCalendar(Stack $sourceItem, string $data): Stack { @@ -290,6 +303,77 @@ private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue return new OptionalNullableValue($done); } + /** + * @return list|null + */ + private function extractCategories(VTodo $todo): ?array { + if (!isset($todo->CATEGORIES)) { + return null; + } + + $values = []; + foreach ($todo->select('CATEGORIES') as $property) { + if ($property instanceof Categories) { + $parts = $property->getParts(); + } else { + $parts = explode(',', (string)$property); + } + foreach ($parts as $part) { + $title = trim((string)$part); + 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) { + $key = mb_strtolower(trim($category)); + if ($key === '' || !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 getDefaultStackIdForBoard(int $boardId): int { $stacks = $this->stackService->findAll($boardId); if (count($stacks) === 0) { From 8a36aa7eea9b6aee9b0d2e2a3d3689df1fecada7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:07:47 +0100 Subject: [PATCH 05/36] fix(caldav): sync CATEGORIES with Deck labels for Thunderbird Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/DeckCalendarBackend.php | 38 +++++++++++++++++++++++++++++++-- lib/Db/CardMapper.php | 7 +++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index de78d58a6b..89be4b885b 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -17,6 +17,7 @@ use OCA\Deck\Model\OptionalNullableValue; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\CardService; +use OCA\Deck\Service\LabelService; use OCA\Deck\Service\PermissionService; use OCA\Deck\Service\StackService; use Sabre\DAV\Exception\NotFound; @@ -39,16 +40,19 @@ class DeckCalendarBackend { private $permissionService; /** @var BoardMapper */ private $boardMapper; + /** @var LabelService */ + private $labelService; public function __construct( BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService, - BoardMapper $boardMapper, + BoardMapper $boardMapper, LabelService $labelService, ) { $this->boardService = $boardService; $this->stackService = $stackService; $this->cardService = $cardService; $this->permissionService = $permissionService; $this->boardMapper = $boardMapper; + $this->labelService = $labelService; } public function getBoards(): array { @@ -348,8 +352,15 @@ private function syncCardCategories(int $cardId, array $categories): void { $targetLabelIds = []; foreach ($categories as $category) { - $key = mb_strtolower(trim($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; @@ -374,6 +385,29 @@ private function syncCardCategories(int $cardId, array $categories): void { } } + 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 { + $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) { diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index f47bea2d95..515afee812 100644 --- a/lib/Db/CardMapper.php +++ b/lib/Db/CardMapper.php @@ -234,7 +234,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) { From 6b3279e8fde028a9ec90ea78427285ae5fdc2013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:13:24 +0100 Subject: [PATCH 06/36] fix(deck): accept null offset in CardMapper findAll for stacks API Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/Db/CardMapper.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index 515afee812..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') From 52501d46cd22f76a0200dd30a008a1f312936f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:15:19 +0100 Subject: [PATCH 07/36] feat(caldav): add Apple Reminders tag mapping via X-APPLE-TAGS Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/DeckCalendarBackend.php | 20 ++++++++++++++++++-- lib/Db/Card.php | 7 ++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 89be4b885b..0934715a13 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -311,12 +311,27 @@ private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue * @return list|null */ private function extractCategories(VTodo $todo): ?array { - if (!isset($todo->CATEGORIES)) { + $hasCategories = isset($todo->CATEGORIES); + $hasAppleTags = false; + foreach ($todo->children() as $child) { + if ($child instanceof Property && strtoupper($child->name) === 'X-APPLE-TAGS') { + $hasAppleTags = true; + break; + } + } + + if (!$hasCategories && !$hasAppleTags) { return null; } $values = []; - foreach ($todo->select('CATEGORIES') as $property) { + $properties = array_merge( + $todo->select('CATEGORIES'), + array_values(array_filter($todo->children(), static function ($child): bool { + return $child instanceof Property && strtoupper($child->name) === 'X-APPLE-TAGS'; + })) + ); + foreach ($properties as $property) { if ($property instanceof Categories) { $parts = $property->getParts(); } else { @@ -324,6 +339,7 @@ private function extractCategories(VTodo $todo): ?array { } foreach ($parts as $part) { $title = trim((string)$part); + $title = ltrim($title, '#'); if ($title !== '') { $values[$title] = true; } diff --git a/lib/Db/Card.php b/lib/Db/Card.php index aa2b8aa36c..64796ebbc0 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -156,9 +156,14 @@ public function getCalendarObject(): VCalendar { } $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(); From eea47cb31057dd694116d106ea3a5c42c4df7e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:21:36 +0100 Subject: [PATCH 08/36] feat(caldav): add Apple tag fallback in DESCRIPTION via Deck-Labels Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/DeckCalendarBackend.php | 50 +++++++++++++++++++++++++++++++-- lib/Db/Card.php | 25 ++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 0934715a13..92120a6d31 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -106,7 +106,7 @@ public function createCalendarObject(int $boardId, string $owner, string $data, } $stackId = $this->resolveStackIdForBoard($boardId, $this->extractStackIdFromRelatedTo($todo)); - $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : ''; + $description = $this->extractDescription($todo); $dueDate = isset($todo->DUE) ? new \DateTime($todo->DUE->getDateTime()->format('c')) : null; $card = $this->cardService->create( @@ -188,7 +188,7 @@ private function updateCardFromCalendar(Card $sourceItem, string $data, bool $re $title = $card->getTitle(); } - $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription(); + $description = isset($todo->DESCRIPTION) ? $this->extractDescription($todo) : $card->getDescription(); $relatedStackId = $this->extractStackIdFromRelatedTo($todo); if ($relatedStackId !== null) { $stackId = $this->resolveStackIdForBoard($boardId, $relatedStackId); @@ -346,9 +346,55 @@ private function extractCategories(VTodo $todo): ?array { } } + foreach ($this->extractInlineDeckLabels($todo) as $label) { + $values[$label] = true; + } + return array_keys($values); } + private function extractDescription(VTodo $todo): string { + if (!isset($todo->DESCRIPTION)) { + return ''; + } + + $description = (string)$todo->DESCRIPTION; + $lines = preg_split('/\R/u', $description) ?: []; + $filtered = array_values(array_filter($lines, static function (string $line): bool { + return preg_match('/^\s*Deck-Labels:\s+/i', $line) !== 1; + })); + return trim(implode("\n", $filtered)); + } + + /** + * @return list + */ + private function extractInlineDeckLabels(VTodo $todo): array { + if (!isset($todo->DESCRIPTION)) { + return []; + } + + $description = (string)$todo->DESCRIPTION; + $lines = preg_split('/\R/u', $description) ?: []; + $labels = []; + foreach ($lines as $line) { + if (preg_match('/^\s*Deck-Labels:\s*(.+)$/i', $line, $matches) !== 1) { + continue; + } + + if (preg_match_all('/#([^\s#,]+)/u', $matches[1], $tagMatches) !== false) { + foreach ($tagMatches[1] as $tag) { + $title = trim(str_replace('-', ' ', $tag)); + if ($title !== '') { + $labels[$title] = true; + } + } + } + } + + return array_keys($labels); + } + /** * @param list $categories */ diff --git a/lib/Db/Card.php b/lib/Db/Card.php index 64796ebbc0..7cec582f42 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -166,11 +166,34 @@ public function getCalendarObject(): VCalendar { } $event->SUMMARY = $this->getTitle(); - $event->DESCRIPTION = $this->getDescription(); + $event->DESCRIPTION = $this->buildCalDavDescription($this->getDescription(), $categoryTitles); $calendar->add($event); return $calendar; } + /** + * @param list $categoryTitles + */ + private function buildCalDavDescription(string $description, array $categoryTitles): string { + $description = rtrim($description); + if (count($categoryTitles) === 0) { + return $description; + } + + $hashTags = array_map(static function (string $title): string { + $tag = preg_replace('/\s+/u', '-', trim($title)); + $tag = trim((string)$tag, '#'); + return '#' . $tag; + }, $categoryTitles); + + $tagLine = 'Deck-Labels: ' . implode(' ', $hashTags); + if ($description === '') { + return $tagLine; + } + + return $description . "\n\n" . $tagLine; + } + public function getDaysUntilDue(): ?int { if ($this->getDuedate() === null) { return null; From 163c92d98be255419f80ca696155d48ca39a2fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:24:15 +0100 Subject: [PATCH 09/36] Revert "feat(caldav): add Apple tag fallback in DESCRIPTION via Deck-Labels" This reverts commit 3d0af5cde40eca834b7d55a56c2adbf7283ab79e. Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/DeckCalendarBackend.php | 50 ++------------------------------- lib/Db/Card.php | 25 +---------------- 2 files changed, 3 insertions(+), 72 deletions(-) diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 92120a6d31..0934715a13 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -106,7 +106,7 @@ public function createCalendarObject(int $boardId, string $owner, string $data, } $stackId = $this->resolveStackIdForBoard($boardId, $this->extractStackIdFromRelatedTo($todo)); - $description = $this->extractDescription($todo); + $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : ''; $dueDate = isset($todo->DUE) ? new \DateTime($todo->DUE->getDateTime()->format('c')) : null; $card = $this->cardService->create( @@ -188,7 +188,7 @@ private function updateCardFromCalendar(Card $sourceItem, string $data, bool $re $title = $card->getTitle(); } - $description = isset($todo->DESCRIPTION) ? $this->extractDescription($todo) : $card->getDescription(); + $description = isset($todo->DESCRIPTION) ? (string)$todo->DESCRIPTION : $card->getDescription(); $relatedStackId = $this->extractStackIdFromRelatedTo($todo); if ($relatedStackId !== null) { $stackId = $this->resolveStackIdForBoard($boardId, $relatedStackId); @@ -346,55 +346,9 @@ private function extractCategories(VTodo $todo): ?array { } } - foreach ($this->extractInlineDeckLabels($todo) as $label) { - $values[$label] = true; - } - return array_keys($values); } - private function extractDescription(VTodo $todo): string { - if (!isset($todo->DESCRIPTION)) { - return ''; - } - - $description = (string)$todo->DESCRIPTION; - $lines = preg_split('/\R/u', $description) ?: []; - $filtered = array_values(array_filter($lines, static function (string $line): bool { - return preg_match('/^\s*Deck-Labels:\s+/i', $line) !== 1; - })); - return trim(implode("\n", $filtered)); - } - - /** - * @return list - */ - private function extractInlineDeckLabels(VTodo $todo): array { - if (!isset($todo->DESCRIPTION)) { - return []; - } - - $description = (string)$todo->DESCRIPTION; - $lines = preg_split('/\R/u', $description) ?: []; - $labels = []; - foreach ($lines as $line) { - if (preg_match('/^\s*Deck-Labels:\s*(.+)$/i', $line, $matches) !== 1) { - continue; - } - - if (preg_match_all('/#([^\s#,]+)/u', $matches[1], $tagMatches) !== false) { - foreach ($tagMatches[1] as $tag) { - $title = trim(str_replace('-', ' ', $tag)); - if ($title !== '') { - $labels[$title] = true; - } - } - } - } - - return array_keys($labels); - } - /** * @param list $categories */ diff --git a/lib/Db/Card.php b/lib/Db/Card.php index 7cec582f42..64796ebbc0 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -166,34 +166,11 @@ public function getCalendarObject(): VCalendar { } $event->SUMMARY = $this->getTitle(); - $event->DESCRIPTION = $this->buildCalDavDescription($this->getDescription(), $categoryTitles); + $event->DESCRIPTION = $this->getDescription(); $calendar->add($event); return $calendar; } - /** - * @param list $categoryTitles - */ - private function buildCalDavDescription(string $description, array $categoryTitles): string { - $description = rtrim($description); - if (count($categoryTitles) === 0) { - return $description; - } - - $hashTags = array_map(static function (string $title): string { - $tag = preg_replace('/\s+/u', '-', trim($title)); - $tag = trim((string)$tag, '#'); - return '#' . $tag; - }, $categoryTitles); - - $tagLine = 'Deck-Labels: ' . implode(' ', $hashTags); - if ($description === '') { - return $tagLine; - } - - return $description . "\n\n" . $tagLine; - } - public function getDaysUntilDue(): ?int { if ($this->getDuedate() === null) { return null; From 1b3a83b3f8180f81dfc1864e2a3f4dbd9dd18588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 01:38:45 +0100 Subject: [PATCH 10/36] feat(caldav): add per-user list mapping modes for CalDAV Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/Calendar.php | 22 +++- lib/DAV/CalendarObject.php | 1 + lib/DAV/CalendarPlugin.php | 39 ++++++- lib/DAV/DeckCalendarBackend.php | 181 ++++++++++++++++++++++++++++- lib/Service/ConfigService.php | 40 ++++++- src/components/DeckAppSettings.vue | 26 +++++ 6 files changed, 295 insertions(+), 14 deletions(-) diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php index 971d58eb09..aff26f9c09 100644 --- a/lib/DAV/Calendar.php +++ b/lib/DAV/Calendar.php @@ -10,6 +10,7 @@ use OCA\DAV\CalDAV\Plugin; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Board; +use OCA\Deck\Db\Stack; use Sabre\CalDAV\CalendarQueryValidator; use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; use Sabre\DAV\Exception\Forbidden; @@ -28,12 +29,15 @@ class Calendar extends ExternalCalendar { 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; } @@ -116,7 +120,8 @@ public function createFile($name, $data = null) { $this->board->getId(), $owner, (string)$data, - $this->extractCardIdFromNormalizedName($normalizedName) + $this->extractCardIdFromNormalizedName($normalizedName), + $this->stack?->getId() ); $this->children = []; } @@ -157,7 +162,11 @@ private function getBackendChildren() { } 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 = []; } @@ -226,8 +235,13 @@ 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']), ]; diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php index d179ec9257..8728d4bfab 100644 --- a/lib/DAV/CalendarObject.php +++ b/lib/DAV/CalendarObject.php @@ -61,6 +61,7 @@ public function put($data) { public function get() { if ($this->sourceItem) { + $this->backend->decorateCalendarObject($this->sourceItem, $this->calendarObject); return $this->calendarObject->serialize(); } } diff --git a/lib/DAV/CalendarPlugin.php b/lib/DAV/CalendarPlugin.php index e7c9ab6b20..c639121afa 100644 --- a/lib/DAV/CalendarPlugin.php +++ b/lib/DAV/CalendarPlugin.php @@ -38,11 +38,23 @@ 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 { @@ -50,9 +62,18 @@ public function hasCalendarInCalendarHome(string $principalUri, string $calendar return false; } - $boards = array_map(static function (Board $board) { - return 'board-' . $board->getId(); - }, $this->backend->getBoards()); + 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 ($calendarUri === 'stack-' . $stack->getId()) { + return true; + } + } + } + return false; + } + + $boards = array_map(static fn (Board $board): string => 'board-' . $board->getId(), $this->backend->getBoards()); return in_array($calendarUri, $boards, true); } @@ -63,6 +84,12 @@ public function getCalendarInCalendarHome(string $principalUri, string $calendar if ($this->hasCalendarInCalendarHome($principalUri, $calendarUri)) { try { + if (str_starts_with($calendarUri, 'stack-')) { + $stack = $this->backend->getStack((int)str_replace('stack-', '', $calendarUri)); + $board = $this->backend->getBoard($stack->getBoardId()); + return new Calendar($principalUri, $calendarUri, $board, $this->backend, $stack); + } + $board = $this->backend->getBoard((int)str_replace('board-', '', $calendarUri)); return new Calendar($principalUri, $calendarUri, $board, $this->backend); } catch (NotFound $e) { diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index 0934715a13..e11d5d6a43 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -17,6 +17,7 @@ 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; @@ -42,10 +43,12 @@ class DeckCalendarBackend { private $boardMapper; /** @var LabelService */ private $labelService; + /** @var ConfigService */ + private $configService; public function __construct( BoardService $boardService, StackService $stackService, CardService $cardService, PermissionService $permissionService, - BoardMapper $boardMapper, LabelService $labelService, + BoardMapper $boardMapper, LabelService $labelService, ConfigService $configService, ) { $this->boardService = $boardService; $this->stackService = $stackService; @@ -53,6 +56,7 @@ public function __construct( $this->permissionService = $permissionService; $this->boardMapper = $boardMapper; $this->labelService = $labelService; + $this->configService = $configService; } public function getBoards(): array { @@ -84,7 +88,21 @@ public function getChildren(int $id): array { ); } - public function createCalendarObject(int $boardId, string $owner, string $data, ?int $preferredCardId = null): Card { + /** @return Stack[] */ + public function getStacks(int $boardId): array { + return $this->stackService->findCalendarEntries($boardId); + } + + public function getStack(int $stackId): Stack { + return $this->stackService->find($stackId); + } + + /** @return Card[] */ + public function getChildrenForStack(int $stackId): array { + return $this->stackService->find($stackId)->getCards() ?? []; + } + + 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) { @@ -105,7 +123,15 @@ public function createCalendarObject(int $boardId, string $owner, string $data, $title = 'New task'; } - $stackId = $this->resolveStackIdForBoard($boardId, $this->extractStackIdFromRelatedTo($todo)); + $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; @@ -138,6 +164,7 @@ public function createCalendarObject(int $boardId, string $owner, string $data, $categories = $this->extractCategories($todo); if ($categories !== null) { + $categories = $this->normalizeCategoriesForLabelSync($boardId, $categories, $mode); $this->syncCardCategories($card->getId(), $categories); } @@ -189,7 +216,11 @@ private function updateCardFromCalendar(Card $sourceItem, string $data, bool $re } $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) { @@ -215,12 +246,40 @@ private function updateCardFromCalendar(Card $sourceItem, string $data, bool $re $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, VCalendar $calendarObject): void { + if (!($sourceItem instanceof Card)) { + return; + } + + $todos = $calendarObject->select('VTODO'); + if (count($todos) === 0 || !($todos[0] instanceof VTodo)) { + return; + } + + $todo = $todos[0]; + $mode = $this->configService->getCalDavListMode(); + $stack = $this->stackService->find($sourceItem->getStackId()); + + 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()); @@ -307,6 +366,122 @@ private function mapDoneFromTodo(VTodo $todo, Card $card): OptionalNullableValue return new OptionalNullableValue($done); } + private function inferStackIdFromTodoHints(int $boardId, VTodo $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) { + $stackTitles[mb_strtolower(trim($stack->getTitle()))] = 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()); + + $targetIndex = (int)round(($priority - 1) * (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; + } + } + + return max(1, min(9, 1 + (int)round($index * 8 / (count($stacks) - 1)))); + } + + private function addTodoCategory(VTodo $todo, string $category): void { + $category = trim($category); + if ($category === '') { + return; + } + + $current = []; + foreach ($todo->select('CATEGORIES') as $property) { + if ($property instanceof Categories) { + 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 */ diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 7b8b031ccc..f87ce93922 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'); @@ -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..287d836e5e 100644 --- a/src/components/DeckAppSettings.vue +++ b/src/components/DeckAppSettings.vue @@ -21,6 +21,12 @@ :label="t('deck', 'Show card ID badge')" /> + @@ -129,6 +135,26 @@ export default { this.$store.dispatch('setConfig', { calendar: newValue }) }, }, + caldavListModeOptions() { + return [ + { id: 'root_tasks', label: this.t('deck', 'Default: lists as root tasks') }, + { id: 'per_list_calendar', label: this.t('deck', 'One calendar per list') }, + { id: 'list_as_category', label: this.t('deck', 'List name as category on each task') }, + { id: 'list_as_priority', label: this.t('deck', 'List position as task priority (1-9)') }, + ] + }, + caldavListModeSelection: { + get() { + const current = this.$store.getters.config('caldavListMode') || 'root_tasks' + return this.caldavListModeOptions.find((option) => option.id === current) || this.caldavListModeOptions[0] + }, + set(option) { + if (!option?.id) { + return + } + this.$store.dispatch('setConfig', { caldavListMode: option.id }) + }, + }, }, beforeMount() { From 8cd33b67b55ba638f4436df2af4f3bad7afb9567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacob=20B=C3=BChler?= Date: Tue, 17 Feb 2026 02:00:32 +0100 Subject: [PATCH 11/36] fix(caldav): refresh mode-based fields and invert list priority mapping - fix Deck settings CalDAV mode selector rendering - make ETag/last-modified depend on selected list mapping mode - map list priority as left=9, right=1 for Thunderbird/Apple Signed-off-by: Jaggob <37583151+Jaggob@users.noreply.github.com> --- lib/DAV/Calendar.php | 5 ++- lib/DAV/CalendarObject.php | 2 +- lib/DAV/DeckCalendarBackend.php | 64 +++++++++++++++++++++++++++++- src/components/DeckAppSettings.vue | 21 +++++----- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/lib/DAV/Calendar.php b/lib/DAV/Calendar.php index aff26f9c09..9771c54275 100644 --- a/lib/DAV/Calendar.php +++ b/lib/DAV/Calendar.php @@ -190,7 +190,10 @@ public function delete() { } public function getLastModified() { - return $this->board->getLastModified(); + $base = $this->board->getLastModified(); + $fingerprint = $this->backend->getCalendarRevisionFingerprint($this->board->getId(), $this->stack?->getId()); + $offset = hexdec(substr(md5($fingerprint), 0, 6)) % 997; + return $base + $offset; } public function getGroup() { diff --git a/lib/DAV/CalendarObject.php b/lib/DAV/CalendarObject.php index 8728d4bfab..afdac26a54 100644 --- a/lib/DAV/CalendarObject.php +++ b/lib/DAV/CalendarObject.php @@ -71,7 +71,7 @@ public function getContentType() { } public function getETag() { - return '"' . md5($this->sourceItem->getLastModified()) . '"'; + return '"' . md5($this->sourceItem->getLastModified() . '|' . $this->backend->getObjectRevisionFingerprint($this->sourceItem)) . '"'; } public function getSize() { diff --git a/lib/DAV/DeckCalendarBackend.php b/lib/DAV/DeckCalendarBackend.php index e11d5d6a43..4f47e65ad9 100644 --- a/lib/DAV/DeckCalendarBackend.php +++ b/lib/DAV/DeckCalendarBackend.php @@ -102,6 +102,64 @@ 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); @@ -435,7 +493,8 @@ private function inferStackIdFromPriority(int $boardId, ?int $priority): ?int { } usort($stacks, static fn (Stack $a, Stack $b) => $a->getOrder() <=> $b->getOrder()); - $targetIndex = (int)round(($priority - 1) * (count($stacks) - 1) / 8); + // 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(); } @@ -454,7 +513,8 @@ private function calculateStackPriority(int $boardId, int $stackId): int { } } - return max(1, min(9, 1 + (int)round($index * 8 / (count($stacks) - 1)))); + // 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(VTodo $todo, string $category): void { diff --git a/src/components/DeckAppSettings.vue b/src/components/DeckAppSettings.vue index 287d836e5e..fee599460c 100644 --- a/src/components/DeckAppSettings.vue +++ b/src/components/DeckAppSettings.vue @@ -21,13 +21,13 @@ :label="t('deck', 'Show card ID badge')" /> - + @@ -69,6 +69,7 @@