Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
905cb59
feat(deck-dav): enable CalDAV write updates for existing Deck items
Feb 16, 2026
d00682f
fix(deck-dav): stabilize completed and delete sync from CalDAV
Feb 16, 2026
fcbd2c6
feat(deck-dav): harden CalDAV move/create flows and client interopera…
Feb 16, 2026
4a60d0c
feat(caldav): map VTODO CATEGORIES to Deck labels
Feb 17, 2026
8a36aa7
fix(caldav): sync CATEGORIES with Deck labels for Thunderbird
Feb 17, 2026
6b3279e
fix(deck): accept null offset in CardMapper findAll for stacks API
Feb 17, 2026
52501d4
feat(caldav): add Apple Reminders tag mapping via X-APPLE-TAGS
Feb 17, 2026
eea47cb
feat(caldav): add Apple tag fallback in DESCRIPTION via Deck-Labels
Feb 17, 2026
163c92d
Revert "feat(caldav): add Apple tag fallback in DESCRIPTION via Deck-…
Feb 17, 2026
1b3a83b
feat(caldav): add per-user list mapping modes for CalDAV
Feb 17, 2026
8cd33b6
fix(caldav): refresh mode-based fields and invert list priority mapping
Feb 17, 2026
46fdd6a
Refactor CalDAV update handling and remove experimental move reattach
Feb 17, 2026
6fac725
Pass board context on delete and expose stable deck card id
Feb 17, 2026
5574e74
Trigger CI
Feb 17, 2026
84bfa95
Stabilize CalDAV backend after review and CI feedback
Feb 17, 2026
5b80756
Harden CalDAV ACL and deleted-card lookup
Feb 17, 2026
b6a24e0
Touch source board on cross-board card moves
Feb 17, 2026
f0f5bf5
Restore permissive CalDAV ACL for client move compatibility
Feb 17, 2026
ec53a1c
Fix CalDAV object name normalization for Thunderbird PUT/MULTIGET
Feb 17, 2026
73420ee
Avoid PROPFIND failure on deck calendar children listing
Feb 17, 2026
7ed1309
Accept both card and deck-card names in CalDAV child lookup
Feb 17, 2026
e437983
Fix CalDAV 412 on Thunderbird cross-board moves
Feb 17, 2026
12a8211
Harden CalDAV backend ACL and type handling
Jaggob Feb 17, 2026
151eb81
Decorate list VTODOs with category and priority
Jaggob Feb 17, 2026
6281cdd
Harden CalDAV fallback handling and lightweight card lookups
Jaggob Feb 17, 2026
452fc61
perf(dav): reduce redundant lookups and serialization work
Jaggob Feb 17, 2026
273d9d0
fix(dav): avoid Thunderbird 404 on stale deck card hrefs
Jaggob Feb 17, 2026
482d109
fix(dav): restrict placeholder resolution for write paths
Jaggob Feb 17, 2026
faa776b
fix(psalm): suppress unresolved Sabre symbols in DAV backend
Jaggob Feb 17, 2026
8844852
fix(frontend): guard optional OC.Files client bootstrap
Jaggob Feb 17, 2026
12dec14
test(dav): cover app-generated calendar URI normalization
Jaggob Feb 17, 2026
d5700ca
test(dav): cover in-progress status and Apple tags label sync
Jaggob Feb 17, 2026
9faaef4
style(test): fix import ordering in dav backend tests
Jaggob Feb 17, 2026
875c711
ci: use ubuntu-latest for fork pull-request workflows
Jaggob Feb 18, 2026
c9333b7
chore(ci): drop workflow runner changes for upstream PR
Jaggob Feb 18, 2026
8f1393f
fix(dav): prevent trailing deletes on stack moves and restore via cre…
Jaggob Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 202 additions & 31 deletions lib/DAV/Calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,18 +24,21 @@ class Calendar extends ExternalCalendar {

/** @var string */
private $principalUri;
/** @var string[] */
private $children;
/** @var array<int, Card|Stack>|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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
);
Comment on lines 151 to 156

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid resolving deleted cards in createFile fallback lookup

createFile() uses getChildNode(..., false) before calling createCalendarObject, and this fallback lookup can return soft-deleted cards. That routes a PUT for card-<id>.ics into the update path instead of the create/restore path; updateCardFromCalendar() then calls cardService->find() (non-deleted) and fails. In practice, recreating/restoring a deleted card via CalDAV with the same href can return an error instead of restoring the task.

Useful? React with 👍 / 👎.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 584dcf5.

This is a rather unlikely edge case for most clients, but: createFile() now resolves existing children with deleted fallback disabled, so soft-deleted cards are no longer routed into the regular update path by getChildNode(...).
This restores correct create/restore behavior for PUT requests on the same card-<id>.ics href.

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 = [];
}
Expand All @@ -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;
}
Expand All @@ -156,13 +214,23 @@ 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();
}

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',
Expand All @@ -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;
Expand All @@ -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;
}
}
}
Loading