diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 4617b86..012d794 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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; @@ -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); diff --git a/lib/Providers/Calendar/CalendarImpl.php b/lib/Providers/Calendar/CalendarImpl.php new file mode 100644 index 0000000..800e8dc --- /dev/null +++ b/lib/Providers/Calendar/CalendarImpl.php @@ -0,0 +1,224 @@ +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()]; + } +} diff --git a/lib/Providers/Calendar/CalendarProvider.php b/lib/Providers/Calendar/CalendarProvider.php new file mode 100644 index 0000000..f328df2 --- /dev/null +++ b/lib/Providers/Calendar/CalendarProvider.php @@ -0,0 +1,56 @@ +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; + } +} diff --git a/lib/Providers/DAV/Calendar/Hybrid/Provider.php b/lib/Providers/DAV/Calendar/Hybrid/Provider.php index a3f3288..5abccc7 100644 --- a/lib/Providers/DAV/Calendar/Hybrid/Provider.php +++ b/lib/Providers/DAV/Calendar/Hybrid/Provider.php @@ -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); @@ -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; } @@ -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; } diff --git a/lib/Store/Local/EventStore.php b/lib/Store/Local/EventStore.php index 447b1bc..e747f46 100644 --- a/lib/Store/Local/EventStore.php +++ b/lib/Store/Local/EventStore.php @@ -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:%')) ) )); }