Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\DAVC\AppInfo;

use OCA\DAVC\Events\UserDeletedListener;
use OCA\DAVC\Providers\Calendar\CalendarProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
Expand All @@ -33,6 +34,9 @@ public function __construct(array $urlParams = []) {
#[\Override]
public function register(IRegistrationContext $context): void {

// register calendar provider so DAV Connector calendars appear in dashboard/search
$context->registerCalendarProvider(CalendarProvider::class);

// register event handlers
$dispatcher = $this->getContainer()->get(IEventDispatcher::class);
$dispatcher->addServiceListener(UserDeletedEvent::class, UserDeletedListener::class);
Expand Down
224 changes: 224 additions & 0 deletions lib/Providers/Calendar/CalendarImpl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAVC\Providers\Calendar;

use DateTimeImmutable;
use DateTimeInterface;
use OCA\DAVC\Store\Common\Filters\FilterComparisonOperator;
use OCA\DAVC\Store\Common\Filters\FilterConjunctionOperator;
use OCA\DAVC\Store\Common\Range\RangeDate;
use OCA\DAVC\Store\Local\CollectionEntity;
use OCA\DAVC\Store\Local\EventStore;
use OCP\Calendar\ICalendar;
use OCP\Constants;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VTimeZone;
use Sabre\VObject\Property;
use Sabre\VObject\Reader;

class CalendarImpl implements ICalendar {

public function __construct(
private readonly CollectionEntity $collection,
private readonly EventStore $store,
) {
}

#[\Override]
public function getKey(): string {
return 'davc_' . $this->collection->getId();
}

#[\Override]
public function getUri(): string {
// Must be a slash-free identifier: Nextcloud wraps this into
// app-generated--dav-wrapper--{uri} and DAV path parsing splits on '/'.
return 'davc_' . $this->collection->getId();
}

#[\Override]
public function getDisplayName(): ?string {
return $this->collection->getLabel();
}

#[\Override]
public function getDisplayColor(): ?string {
return $this->collection->getColor();
}

#[\Override]
public function getPermissions(): int {
return Constants::PERMISSION_READ;
}

#[\Override]
public function isDeleted(): bool {
return false;
}

#[\Override]
public function search(string $pattern, array $searchProperties = [], array $options = [], ?int $limit = null, ?int $offset = null): array {
$filter = $this->store->entityListFilter();
$filter->condition('cid', $this->collection->getId(), FilterComparisonOperator::EQ, FilterConjunctionOperator::AND);

$range = null;
$start = $options['timerange']['start'] ?? null;
$end = $options['timerange']['end'] ?? null;
if ($start instanceof DateTimeInterface || $end instanceof DateTimeInterface) {
$range = new RangeDate(
$start instanceof DateTimeInterface ? $start : new DateTimeImmutable('@0'),
$end instanceof DateTimeInterface ? $end : new DateTimeImmutable('@9999999999'),
);
}

$entities = $this->store->entityList($filter, null, $range);

$results = [];
foreach ($entities as $entity) {
$data = $entity->getData();
if ($data === null) {
continue;
}

try {
$vCalendar = Reader::read($data);
} catch (\Throwable) {
continue;
}

if (!($vCalendar instanceof VCalendar)) {
continue;
}

if ($start instanceof DateTimeInterface && $end instanceof DateTimeInterface) {
$vCalendar = $vCalendar->expand($start, $end);
}

$components = $vCalendar->getComponents();
$objects = [];
$timezones = [];
foreach ($components as $comp) {
if ($comp instanceof VTimeZone) {
$timezones[] = $this->transformComponent($comp);
} else {
// Skip instances whose start is before the range start so that
// stale occurrences don't consume result slots for future events.
// DATE-only events are compared by calendar date (not timestamp) so
// that today's all-day events are never treated as "past".
if ($start instanceof DateTimeInterface && $comp->DTSTART !== null) {
$dtStart = $comp->DTSTART->getDateTime();
if ($dtStart instanceof DateTimeInterface) {
$isDateOnly = !$comp->DTSTART->hasTime();
if ($isDateOnly) {
$startDay = (int)(new DateTimeImmutable($start->format('Y-m-d'), new \DateTimeZone('UTC')))->format('U');
if ($dtStart->getTimestamp() < $startDay) {
continue;
}
} elseif ($dtStart->getTimestamp() < $start->getTimestamp()) {
continue;
}
}
}
$objects[] = $this->transformComponent($comp);
}
}

if (empty($objects)) {
continue;
}

if ($pattern !== '') {
$matched = false;
foreach ($objects as $obj) {
foreach ($searchProperties as $prop) {
$value = $obj[$prop][0] ?? null;
if (is_string($value) && stripos($value, $pattern) !== false) {
$matched = true;
break 2;
}
}
if (empty($searchProperties)) {
$matched = true;
}
}
if (!$matched) {
continue;
}
}

$results[] = [
'id' => $entity->getId(),
'type' => 'VEVENT',
'uid' => $entity->getUuid() ?? '',
'uri' => $entity->getCeid() ?? '',
'objects' => $objects,
'timezones' => $timezones,
];
}

usort($results, static function (array $a, array $b): int {
$startA = $a['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable('+10 years');
$startB = $b['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable('+10 years');
if (!($startA instanceof DateTimeInterface)) {
$startA = new DateTimeImmutable('+10 years');
}
if (!($startB instanceof DateTimeInterface)) {
$startB = new DateTimeImmutable('+10 years');
}
return $startA->getTimestamp() <=> $startB->getTimestamp();
});

if ($offset !== null) {
$results = array_slice($results, $offset);
}
// Do not apply the caller's limit: a widget passing limit=7 would silently drop
// events that sort beyond position 7 (e.g. all-day events after several timed ones).
// The dashboard widget sorts all calendars' results together and handles display limits itself.

return $results;
}

private function transformComponent(Component $comp): array {
$data = [];
$validationRules = $comp->getValidationRules();

foreach ($comp->getComponents() as $subComp) {
$name = $subComp->name;
$data[$name][] = $this->transformComponent($subComp);
}

foreach ($comp->children() as $child) {
if (!($child instanceof Property)) {
continue;
}
$name = $child->name;
$rule = $validationRules[$name] ?? '*';
$value = $this->transformProperty($child);

if ($rule === '+' || $rule === '*') {
$data[$name][] = $value;
} else {
$data[$name] = $value;
}
}

return $data;
}

private function transformProperty(Property $prop): mixed {
if ($prop instanceof Property\ICalendar\DateTime) {
$value = $prop->getDateTime();
} else {
$value = $prop->getValue();
}
return [$value, $prop->parameters()];
}
}
56 changes: 56 additions & 0 deletions lib/Providers/Calendar/CalendarProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAVC\Providers\Calendar;

use OCA\DAVC\Store\Local\EventStore;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarProvider;
use OCP\IRequest;

class CalendarProvider implements ICalendarProvider {

public function __construct(
private readonly EventStore $store,
private readonly IRequest $request,
) {
}

/**
* @return ICalendar[]
*/
#[\Override]
public function getCalendars(string $principalUri, array $calendarUris = []): array {
// AppCalendarPlugin (core) wraps every ICalendarProvider calendar into
// app-generated--dav-wrapper--{uri}, which would duplicate the ExternalCalendar
// objects already served by our Sabre DAV Provider. Returning [] during CalDAV
// requests prevents the duplicate; the dashboard/search path is unaffected.
if (str_starts_with($this->request->getRequestUri(), '/remote.php/dav')) {
return [];
}

$parts = explode('/', $principalUri);
$uid = end($parts);
if ($uid === false || $uid === '') {
return [];
}

$collections = $this->store->collectionListByUser($uid);

$calendars = [];
foreach ($collections as $collection) {
if (!empty($calendarUris) && !in_array('davc_' . $collection->getId(), $calendarUris, true)) {
continue;
}
$calendars[] = new CalendarImpl($collection, $this->store);
}

return $calendars;
}
}
11 changes: 0 additions & 11 deletions lib/Providers/DAV/Calendar/Hybrid/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,9 @@ public function getCalendars(string $principalUri, array $calendarUris = []): ar
*/
public function fetchAllForCalendarHome(string $principalUri): array {
$userId = $this->extractUserId($principalUri);
// construct filter
$listFilter = $this->localService->collectionListFilter();
$listFilter->condition('uid', $userId);
// retrieve collection(s)
$collections = $this->localService->collectionList($listFilter);
// construct collection objects list
$list = [];
foreach ($collections as $entry) {
$collection = $this->collectionFromModel($entry);
Expand All @@ -77,23 +74,19 @@ public function fetchAllForCalendarHome(string $principalUri): array {
*/
public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool {
$userId = $this->extractUserId($principalUri);
// check if collection is already cached
$collection = $this->cacheRetrieveCollection($userId, $calendarUri);
if ($collection) {
return true;
}
// construct filter
$listFilter = $this->localService->collectionListFilter();
$listFilter->condition('uid', $userId);
$listFilter->condition('uuid', $calendarUri);
// check if collection exists in store
$collections = $this->localService->collectionList($listFilter);
if ($collections !== []) {
$collection = $this->collectionFromModel(reset($collections));
$this->cacheStoreCollection($userId, $calendarUri, $collection);
return true;
}
// collection not found
return false;
}

Expand All @@ -102,23 +95,19 @@ public function hasCalendarInCalendarHome(string $principalUri, string $calendar
*/
public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar {
$userId = $this->extractUserId($principalUri);
// check if collection is already cached
$collection = $this->cacheRetrieveCollection($userId, $calendarUri);
if ($collection) {
return $collection;
}
// construct filter
$listFilter = $this->localService->collectionListFilter();
$listFilter->condition('uid', $userId);
$listFilter->condition('uuid', $calendarUri);
// check if collection exists in store
$collections = $this->localService->collectionList($listFilter);
if (count($collections) > 0) {
$collection = $this->collectionFromModel(reset($collections));
$this->cacheStoreCollection($userId, $calendarUri, $collection);
return $collection;
}
// collection not found
return null;
}

Expand Down
14 changes: 10 additions & 4 deletions lib/Store/Local/EventStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,27 +60,33 @@ public function entityList(?IFilter $filter = null, ?ISort $sort = null, ?IRange
$rangerStart = $range->getStart()->format('U');
$rangerEnd = $range->getEnd()->format('U');
$cmd->andWhere($cmd->expr()->orX(
// case 1
// case 1: event starts and ends within range
$cmd->expr()->andX(
$cmd->expr()->gte('startson', $cmd->createNamedParameter($rangerStart)),
$cmd->expr()->lte('endson', $cmd->createNamedParameter($rangerEnd)),
),
// case 2
// case 2: event starts before range and ends within range
$cmd->expr()->andX(
$cmd->expr()->lt('startson', $cmd->createNamedParameter($rangerStart)),
$cmd->expr()->gte('endson', $cmd->createNamedParameter($rangerStart)),
$cmd->expr()->lte('endson', $cmd->createNamedParameter($rangerEnd))
),
// case 3
// case 3: event starts within range and ends after range
$cmd->expr()->andX(
$cmd->expr()->gte('startson', $cmd->createNamedParameter($rangerStart)),
$cmd->expr()->lte('startson', $cmd->createNamedParameter($rangerEnd)),
$cmd->expr()->gt('endson', $cmd->createNamedParameter($rangerEnd))
),
// case 4
// case 4: event spans entire range
$cmd->expr()->andX(
$cmd->expr()->lt('startson', $cmd->createNamedParameter($rangerStart)),
$cmd->expr()->gt('endson', $cmd->createNamedParameter($rangerEnd))
),
// case 5: recurring event with base occurrence before range;
// expand() in CalendarImpl generates and filters the actual instances
$cmd->expr()->andX(
$cmd->expr()->lt('startson', $cmd->createNamedParameter($rangerStart)),
$cmd->expr()->like('data', $cmd->createNamedParameter('%RRULE:%'))
)
));
}
Expand Down