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
69 changes: 45 additions & 24 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -2370,6 +2370,10 @@ private function searchCalendarObjects(IQueryBuilder $query, ?DateTimeInterface
}

try {
// The time-range filter is hardcoded to VEVENT: Sabre only
// expands VEVENT recurrences (EventIterator is VEVENT-only and
// VTodo::isInTimeRange ignores RRULE), so other component types
// would not be filtered correctly here.
$isValid = $this->validateFilterForObject($row, [
'name' => 'VCALENDAR',
'comp-filters' => [
Expand Down Expand Up @@ -2473,13 +2477,24 @@ private function transformSearchProperty(Property $prop) {
}

/**
* Search calendar objects across a principal's calendars.
*
* This returns the stored calendar objects and does not expand recurring
* events. Callers that need the concrete occurrence for a requested time
* range must expand recurrences from `calendardata` themselves.
*
* Note: when a `timerange` option is given, the precise filtering assumes
* VEVENT components (see searchCalendarObjects()). Passing other component
* types together with a `timerange` would drop all results.
*
* @param string $principalUri
* @param string $pattern
* @param array $componentTypes
* @param array $searchProperties
* @param array $searchParameters
* @param array $options
* @return array
*
* @return list<array{uri: string, calendarid: int, calendartype: int, calendardata: string}>
*/
public function searchPrincipalUri(string $principalUri,
string $pattern,
Expand All @@ -2495,6 +2510,11 @@ public function searchPrincipalUri(string $principalUri,
$calendarOr = [];
$searchOr = [];

$start = null;
$end = null;

// Todo: The retries when $hasLimit && $hasTimeRange from https://github.com/nextcloud/server/pull/45222 should also be applied here to the calendarObjectIdQuery

// Fetch calendars and subscription
$calendars = $this->getCalendarsForUser($principalUri);
$subscriptions = $this->getSubscriptionsForUser($principalUri);
Expand Down Expand Up @@ -2573,19 +2593,21 @@ public function searchPrincipalUri(string $principalUri,
if (isset($options['offset'])) {
$calendarObjectIdQuery->setFirstResult($options['offset']);
}
if (isset($options['timerange'])) {
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
'lastoccurence',
$calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()),
));
}
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
'firstoccurence',
$calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()),
));
}
if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
/** @var DateTimeInterface $start */
$start = $options['timerange']['start'];
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
'lastoccurence',
$calendarObjectIdQuery->createNamedParameter($start->getTimestamp()),
));
}
if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
/** @var DateTimeInterface $end */
$end = $options['timerange']['end'];
$calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
'firstoccurence',
$calendarObjectIdQuery->createNamedParameter($end->getTimestamp()),
));
}

$result = $calendarObjectIdQuery->executeQuery();
Expand All @@ -2600,17 +2622,16 @@ public function searchPrincipalUri(string $principalUri,
->from('calendarobjects')
->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));

$result = $query->executeQuery();
$calendarObjects = [];
while (($array = $result->fetchAssociative()) !== false) {
$array['calendarid'] = (int)$array['calendarid'];
$array['calendartype'] = (int)$array['calendartype'];
$array['calendardata'] = $this->readBlob($array['calendardata']);
$calendarObjects = $this->searchCalendarObjects($query, $start, $end);

$calendarObjects[] = $array;
}
$result->closeCursor();
return $calendarObjects;
return array_values(array_map(function ($event) {
return [
'uri' => (string)$event['uri'],
'calendarid' => (int)$event['calendarid'],
'calendartype' => (int)$event['calendartype'],
'calendardata' => (string)$this->readBlob($event['calendardata']),
];
}, $calendarObjects));
}, $this->db);
}

Expand Down
28 changes: 13 additions & 15 deletions apps/dav/lib/Search/ACalendarSearchProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use OCP\IURLGenerator;
use OCP\Search\IProvider;
use Sabre\VObject\Component;
use Sabre\VObject\Reader;
use Sabre\VObject\Component\VCalendar;

/**
* Class ACalendarSearchProvider
Expand Down Expand Up @@ -76,34 +76,32 @@ protected function getSortedSubscriptions(string $principalUri): array {

/**
* Returns the primary VEvent / VJournal / VTodo component
*
* If it's a component with recurrence-ids, it will return
* the primary component
*
* TODO: It would be a nice enhancement to show recurrence-exceptions
* as individual search-results.
*
* For now we will just display the primary element of a recurrence-set.
*
* @param string $calendarData
* Returns null when the calendar has no component of the requested type.
*
* @param VCalendar $vCalendar
* @param string $componentName
* @return Component
* @return Component|null
*/
protected function getPrimaryComponent(string $calendarData, string $componentName): Component {
$vCalendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);

$components = $vCalendar->select($componentName);
if (count($components) === 1) {
return $components[0];
}

// If it's a recurrence-set, take the primary element
foreach ($components as $component) {
protected function getPrimaryComponent(VCalendar $vCalendar, string $componentName): ?Component {
$first = null;
foreach ($vCalendar->select($componentName) as $component) {
/** @var Component $component */
// Prefer the recurrence-set master (no RECURRENCE-ID); otherwise the first element.
$first ??= $component;
if (!$component->{'RECURRENCE-ID'}) {
return $component;
}
}

// In case of error, just fallback to the first element in the set
return $components[0];
return $first;
}
}
159 changes: 121 additions & 38 deletions apps/dav/lib/Search/EventsSearchProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@

namespace OCA\DAV\Search;

use DateTimeImmutable;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\IUser;
use OCP\Search\IFilteringProvider;
use OCP\Search\ISearchQuery;
use OCP\Search\SearchResult;
use OCP\Search\SearchResultEntry;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\DateTimeParser;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Property;
use Sabre\VObject\Property\ICalendar\DateTime;
use function array_combine;
use function array_fill;
use function array_key_exists;
use function array_map;
use Sabre\VObject\Reader;
use function array_push;
use function array_values;

/**
* Class EventsSearchProvider
Expand Down Expand Up @@ -101,6 +103,20 @@ public function search(

/** @var string|null $term */
$term = $query->getFilter('term')?->get();

$since = $query->getFilter('since')?->get();
$until = $query->getFilter('until')?->get();

if ($since !== null && $until === null) {
$until = new DateTimeImmutable('now', new \DateTimeZone('Z'));
}

/** @var array{start: DateTimeImmutable|null, end: DateTimeImmutable|null} $timeRange */
$timeRange = [
'start' => $since,
'end' => $until,
];

if ($term === null) {
$searchResults = [];
} else {
Expand All @@ -113,18 +129,15 @@ public function search(
[
'limit' => $query->getLimit(),
'offset' => $query->getCursor(),
'timerange' => [
'start' => $query->getFilter('since')?->get(),
'end' => $query->getFilter('until')?->get(),
],
'timerange' => $timeRange,
]
);
}
/** @var IUser|null $person */
$person = $query->getFilter('person')?->get();
$personDisplayName = $person?->getDisplayName();
if ($personDisplayName !== null) {
$attendeeSearchResults = $this->backend->searchPrincipalUri(
array_push($searchResults, ...$this->backend->searchPrincipalUri(
$principalUri,
$personDisplayName,
[self::COMPONENT_TYPE],
Expand All @@ -133,55 +146,125 @@ public function search(
[
'limit' => $query->getLimit(),
'offset' => $query->getCursor(),
'timerange' => [
'start' => $query->getFilter('since')?->get(),
'end' => $query->getFilter('until')?->get(),
],
'timerange' => $timeRange,
],
);
));
}

$searchResultIndex = array_combine(
array_map(fn ($event) => $event['calendarid'] . '-' . $event['uri'], $searchResults),
array_fill(0, count($searchResults), null),
);
foreach ($attendeeSearchResults as $attendeeResult) {
if (array_key_exists($attendeeResult['calendarid'] . '-' . $attendeeResult['uri'], $searchResultIndex)) {
// Duplicate
continue;
}
$searchResults[] = $attendeeResult;
// Resolve each row to its in-range component (deduplicating events that
// matched both the term and attendee searches, keyed by calendarid-uri, and
// dropping anything that does not resolve to a usable in-range component) and
// format it.
$formattedResults = [];
foreach ($searchResults as $searchResult) {
$key = $searchResult['calendarid'] . '-' . $searchResult['uri'];
if (isset($formattedResults[$key])) {
continue;
}
$component = $this->resolveComponent($searchResult['calendardata'], $since, $until);
if ($component === null) {
continue;
}
}
$formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById): SearchResultEntry {
$component = $this->getPrimaryComponent($eventRow['calendardata'], self::COMPONENT_TYPE);
$title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event'));

if ($eventRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) {
$calendar = $calendarsById[$eventRow['calendarid']];
$title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event'));
if ($searchResult['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) {
$calendar = $calendarsById[$searchResult['calendarid']];
} else {
$calendar = $subscriptionsById[$eventRow['calendarid']];
$calendar = $subscriptionsById[$searchResult['calendarid']];
}
$subline = $this->generateSubline($component, $calendar);
$resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $eventRow['uri']);
$resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $searchResult['uri']);
$result = new SearchResultEntry('', $title, $subline, $resourceUrl, 'icon-calendar-dark', false);

$dtStart = $component->DTSTART;

if ($dtStart instanceof DateTime) {
$startDateTime = $dtStart->getDateTime()->format('U');
$result->addAttribute('createdAt', $startDateTime);
$result->addAttribute('createdAt', $dtStart->getDateTime()->format('U'));
}

return $result;
}, $searchResults);
$formattedResults[$key] = $result;
}

return SearchResult::paginated(
$this->getName(),
$formattedResults,
array_values($formattedResults),
$query->getCursor() + count($formattedResults)
);
Comment on lines 187 to 191
}

/**
* Resolve the component to display for a result row.
*
* Parses the calendar data and, when a time range is requested,
* expands it to the in-range occurrence. Returns null to drop the row when the
* data is not a calendar or has no occurrence within since and until.
*/
private function resolveComponent(string $calendarData, ?\DateTimeInterface $since, ?\DateTimeInterface $until): ?Component {
$document = Reader::read($calendarData, Reader::OPTION_FORGIVING);
if (!$document instanceof VCalendar) {
return null;
}
Comment on lines +201 to +205

if ($since !== null && $until !== null) {
$document = $this->expandInRange($document, $since, $until);
if ($document === null) {
return null;
}
}

return $this->getPrimaryComponent($document, self::COMPONENT_TYPE);
}

/**
* Expand a recurring event into its occurrences within the requested
* [$since, $until] window, converted back from the UTC that expand() forces
* into the event's original timezone.
*
* Returns null when the event has no occurrence in range (recurrence gap) or
* cannot be expanded.
*/
private function expandInRange(VCalendar $vCalendar, \DateTimeInterface $since, \DateTimeInterface $until): ?VCalendar {
// expand() rewrites every occurrence's DTSTART/DTEND to UTC, so remember
// the event's original timezone to display the occurrence in local time.
$originalTimeZone = null;
$baseComponent = $vCalendar->getBaseComponent(self::COMPONENT_TYPE);
if ($baseComponent !== null && isset($baseComponent->DTSTART) && $baseComponent->DTSTART->hasTime()) {
$originalTimeZone = $baseComponent->DTSTART->getDateTime()->getTimezone();
}

try {
$expanded = $vCalendar->expand($since, $until);
} catch (InvalidDataException $e) {
return null;
}

$occurrences = $expanded->select(self::COMPONENT_TYPE);
if ($occurrences === []) {
return null;
}

if ($originalTimeZone !== null) {
foreach ($occurrences as $occurrence) {
$this->applyTimeZone($occurrence, $originalTimeZone);
}
}

return $expanded;
}

/**
* Move the occurrence back into the event's original timezone after expand()
* has rewritten it to UTC, so the rendered time matches the user's local time.
*/
private function applyTimeZone(Component $component, \DateTimeZone $timeZone): void {
foreach (['DTSTART', 'DTEND'] as $name) {
if (isset($component->$name) && $component->$name->hasTime()) {
$component->$name->setDateTime(
$component->$name->getDateTime()->setTimezone($timeZone),
);
}
}
}

protected function getDeepLinkToCalendarApp(
string $principalUri,
string $calendarUri,
Expand Down
Loading
Loading