]> source.dussan.org Git - nextcloud-server.git/commitdiff
Add Event and Task Backends for Unified Search 22020/head
authorGeorg Ehrke <developer@georgehrke.com>
Mon, 27 Jul 2020 14:14:15 +0000 (16:14 +0200)
committerGeorg Ehrke <developer@georgehrke.com>
Tue, 4 Aug 2020 14:01:59 +0000 (16:01 +0200)
Signed-off-by: Georg Ehrke <developer@georgehrke.com>
14 files changed:
apps/dav/composer/composer/autoload_classmap.php
apps/dav/composer/composer/autoload_static.php
apps/dav/lib/AppInfo/Application.php
apps/dav/lib/CalDAV/CalDavBackend.php
apps/dav/lib/Search/ACalendarSearchProvider.php [new file with mode: 0644]
apps/dav/lib/Search/EventsSearchProvider.php [new file with mode: 0644]
apps/dav/lib/Search/EventsSearchResultEntry.php [new file with mode: 0644]
apps/dav/lib/Search/TasksSearchProvider.php [new file with mode: 0644]
apps/dav/lib/Search/TasksSearchResultEntry.php [new file with mode: 0644]
apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php
apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
apps/dav/tests/unit/Search/ContactsSearchProviderTest.php
apps/dav/tests/unit/Search/EventsSearchProviderTest.php [new file with mode: 0644]
apps/dav/tests/unit/Search/TasksSearchProviderTest.php [new file with mode: 0644]

index bd63dee13b7d97be0925ff0bc4946cf094fcf6b7..081f334a4f911417aa14c1dd7f7007e52aa565ef 100644 (file)
@@ -210,8 +210,13 @@ return array(
     'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningNode.php',
     'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php',
     'OCA\\DAV\\RootCollection' => $baseDir . '/../lib/RootCollection.php',
+    'OCA\\DAV\\Search\\ACalendarSearchProvider' => $baseDir . '/../lib/Search/ACalendarSearchProvider.php',
     'OCA\\DAV\\Search\\ContactsSearchProvider' => $baseDir . '/../lib/Search/ContactsSearchProvider.php',
     'OCA\\DAV\\Search\\ContactsSearchResultEntry' => $baseDir . '/../lib/Search/ContactsSearchResultEntry.php',
+    'OCA\\DAV\\Search\\EventsSearchProvider' => $baseDir . '/../lib/Search/EventsSearchProvider.php',
+    'OCA\\DAV\\Search\\EventsSearchResultEntry' => $baseDir . '/../lib/Search/EventsSearchResultEntry.php',
+    'OCA\\DAV\\Search\\TasksSearchProvider' => $baseDir . '/../lib/Search/TasksSearchProvider.php',
+    'OCA\\DAV\\Search\\TasksSearchResultEntry' => $baseDir . '/../lib/Search/TasksSearchResultEntry.php',
     'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php',
     'OCA\\DAV\\Settings\\CalDAVSettings' => $baseDir . '/../lib/Settings/CalDAVSettings.php',
     'OCA\\DAV\\Storage\\PublicOwnerWrapper' => $baseDir . '/../lib/Storage/PublicOwnerWrapper.php',
index a664c86f5fd8eaef27d3bccf8edc0213018bfd9d..3bfdb3b8628a61539e041608dab2a8a78d2f948d 100644 (file)
@@ -225,8 +225,13 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningNode.php',
         'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php',
         'OCA\\DAV\\RootCollection' => __DIR__ . '/..' . '/../lib/RootCollection.php',
+        'OCA\\DAV\\Search\\ACalendarSearchProvider' => __DIR__ . '/..' . '/../lib/Search/ACalendarSearchProvider.php',
         'OCA\\DAV\\Search\\ContactsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchProvider.php',
         'OCA\\DAV\\Search\\ContactsSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/ContactsSearchResultEntry.php',
+        'OCA\\DAV\\Search\\EventsSearchProvider' => __DIR__ . '/..' . '/../lib/Search/EventsSearchProvider.php',
+        'OCA\\DAV\\Search\\EventsSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/EventsSearchResultEntry.php',
+        'OCA\\DAV\\Search\\TasksSearchProvider' => __DIR__ . '/..' . '/../lib/Search/TasksSearchProvider.php',
+        'OCA\\DAV\\Search\\TasksSearchResultEntry' => __DIR__ . '/..' . '/../lib/Search/TasksSearchResultEntry.php',
         'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php',
         'OCA\\DAV\\Settings\\CalDAVSettings' => __DIR__ . '/..' . '/../lib/Settings/CalDAVSettings.php',
         'OCA\\DAV\\Storage\\PublicOwnerWrapper' => __DIR__ . '/..' . '/../lib/Storage/PublicOwnerWrapper.php',
index 6f2f7b291534285f9e9350c0502cb2c43edb25a0..1bad3cb1ebac22acf0345104e77e1dd3ce95076c 100644 (file)
@@ -55,6 +55,8 @@ use OCA\DAV\CardDAV\PhotoCache;
 use OCA\DAV\CardDAV\SyncService;
 use OCA\DAV\HookManager;
 use OCA\DAV\Search\ContactsSearchProvider;
+use OCA\DAV\Search\EventsSearchProvider;
+use OCA\DAV\Search\TasksSearchProvider;
 use OCP\AppFramework\App;
 use OCP\AppFramework\Bootstrap\IBootContext;
 use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -102,6 +104,8 @@ class Application extends App implements IBootstrap {
                 * Register Search Providers
                 */
                $context->registerSearchProvider(ContactsSearchProvider::class);
+               $context->registerSearchProvider(EventsSearchProvider::class);
+               $context->registerSearchProvider(TasksSearchProvider::class);
        }
 
        public function boot(IBootContext $context): void {
index ddfb0a641e5db9ad2b7e2e454ddab959e9878137..5cddf6e84b6ff10ff27101c6aa49d5dea12bd3ed 100644 (file)
@@ -1669,6 +1669,124 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
                ];
        }
 
+       /**
+        * @param string $principalUri
+        * @param string $pattern
+        * @param array $componentTypes
+        * @param array $searchProperties
+        * @param array $searchParameters
+        * @param array $options
+        * @return array
+        */
+       public function searchPrincipalUri(string $principalUri,
+                                                                          string $pattern,
+                                                                          array $componentTypes,
+                                                                          array $searchProperties,
+                                                                          array $searchParameters,
+                                                                          array $options = []): array {
+               $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
+
+               $calendarObjectIdQuery = $this->db->getQueryBuilder();
+               $calendarOr = $calendarObjectIdQuery->expr()->orX();
+               $searchOr = $calendarObjectIdQuery->expr()->orX();
+
+               // Fetch calendars and subscription
+               $calendars = $this->getCalendarsForUser($principalUri);
+               $subscriptions = $this->getSubscriptionsForUser($principalUri);
+               foreach ($calendars as $calendar) {
+                       $calendarAnd = $calendarObjectIdQuery->expr()->andX();
+                       $calendarAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$calendar['id'])));
+                       $calendarAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)));
+
+                       // If it's shared, limit search to public events
+                       if ($calendar['principaluri'] !== $calendar['{http://owncloud.org/ns}owner-principal']) {
+                               $calendarAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC)));
+                       }
+
+                       $calendarOr->add($calendarAnd);
+               }
+               foreach ($subscriptions as $subscription) {
+                       $subscriptionAnd = $calendarObjectIdQuery->expr()->andX();
+                       $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$subscription['id'])));
+                       $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)));
+
+                       // If it's shared, limit search to public events
+                       if ($subscription['principaluri'] !== $subscription['{http://owncloud.org/ns}owner-principal']) {
+                               $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC)));
+                       }
+
+                       $calendarOr->add($subscriptionAnd);
+               }
+
+               foreach ($searchProperties as $property) {
+                       $propertyAnd = $calendarObjectIdQuery->expr()->andX();
+                       $propertyAnd->add($calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)));
+                       $propertyAnd->add($calendarObjectIdQuery->expr()->isNull('cob.parameter'));
+
+                       $searchOr->add($propertyAnd);
+               }
+               foreach ($searchParameters as $property => $parameter) {
+                       $parameterAnd = $calendarObjectIdQuery->expr()->andX();
+                       $parameterAnd->add($calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)));
+                       $parameterAnd->add($calendarObjectIdQuery->expr()->eq('cob.parameter', $calendarObjectIdQuery->createNamedParameter($parameter, IQueryBuilder::PARAM_STR)));
+
+                       $searchOr->add($parameterAnd);
+               }
+
+               if ($calendarOr->count() === 0) {
+                       return [];
+               }
+               if ($searchOr->count() === 0) {
+                       return [];
+               }
+
+               $calendarObjectIdQuery->selectDistinct('cob.objectid')
+                       ->from($this->dbObjectPropertiesTable, 'cob')
+                       ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid'))
+                       ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY)))
+                       ->andWhere($calendarOr)
+                       ->andWhere($searchOr);
+
+               if ('' !== $pattern) {
+                       if (!$escapePattern) {
+                               $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter($pattern)));
+                       } else {
+                               $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
+                       }
+               }
+
+               if (isset($options['limit'])) {
+                       $calendarObjectIdQuery->setMaxResults($options['limit']);
+               }
+               if (isset($options['offset'])) {
+                       $calendarObjectIdQuery->setFirstResult($options['offset']);
+               }
+
+               $result = $calendarObjectIdQuery->execute();
+               $matches = $result->fetchAll();
+               $result->closeCursor();
+               $matches = array_map(static function (array $match):int {
+                       return (int) $match['objectid'];
+               }, $matches);
+
+               $query = $this->db->getQueryBuilder();
+               $query->select('calendardata', 'uri', 'calendarid', 'calendartype')
+                       ->from('calendarobjects')
+                       ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));
+
+               $result = $query->execute();
+               $calendarObjects = $result->fetchAll();
+               $result->closeCursor();
+
+               return array_map(function (array $array): array {
+                       $array['calendarid'] = (int)$array['calendarid'];
+                       $array['calendartype'] = (int)$array['calendartype'];
+                       $array['calendardata'] = $this->readBlob($array['calendardata']);
+
+                       return $array;
+               }, $calendarObjects);
+       }
+
        /**
         * Searches through all of a users calendars and calendar objects to find
         * an object with a specific UID.
diff --git a/apps/dav/lib/Search/ACalendarSearchProvider.php b/apps/dav/lib/Search/ACalendarSearchProvider.php
new file mode 100644 (file)
index 0000000..56273fe
--- /dev/null
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCP\App\IAppManager;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\Search\IProvider;
+use Sabre\VObject\Component;
+use Sabre\VObject\Reader;
+
+/**
+ * Class ACalendarSearchProvider
+ *
+ * @package OCA\DAV\Search
+ */
+abstract class ACalendarSearchProvider implements IProvider {
+
+       /** @var IAppManager */
+       protected $appManager;
+
+       /** @var IL10N */
+       protected $l10n;
+
+       /** @var IURLGenerator */
+       protected $urlGenerator;
+
+       /** @var CalDavBackend */
+       protected $backend;
+
+       /**
+        * ACalendarSearchProvider constructor.
+        *
+        * @param IAppManager $appManager
+        * @param IL10N $l10n
+        * @param IURLGenerator $urlGenerator
+        * @param CalDavBackend $backend
+        */
+       public function __construct(IAppManager $appManager,
+                                                               IL10N $l10n,
+                                                               IURLGenerator $urlGenerator,
+                                                               CalDavBackend $backend) {
+               $this->appManager = $appManager;
+               $this->l10n = $l10n;
+               $this->urlGenerator = $urlGenerator;
+               $this->backend = $backend;
+       }
+
+       /**
+        * Get an associative array of calendars
+        * calendarId => calendar
+        *
+        * @param string $principalUri
+        * @return array
+        */
+       protected function getSortedCalendars(string $principalUri): array {
+               $calendars = $this->backend->getCalendarsForUser($principalUri);
+               $calendarsById = [];
+               foreach ($calendars as $calendar) {
+                       $calendarsById[(int) $calendar['id']] = $calendar;
+               }
+
+               return $calendarsById;
+       }
+
+       /**
+        * Get an associative array of subscriptions
+        * subscriptionId => subscription
+        *
+        * @param string $principalUri
+        * @return array
+        */
+       protected function getSortedSubscriptions(string $principalUri): array {
+               $subscriptions = $this->backend->getSubscriptionsForUser($principalUri);
+               $subscriptionsById = [];
+               foreach ($subscriptions as $subscription) {
+                       $subscriptionsById[(int) $subscription['id']] = $subscription;
+               }
+
+               return $subscriptionsById;
+       }
+
+       /**
+        * 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
+        * @param string $componentName
+        * @return Component
+        */
+       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) {
+                       /** @var Component $component */
+                       if (!$component->{'RECURRENCE-ID'}) {
+                               return $component;
+                       }
+               }
+
+               // In case of error, just fallback to the first element in the set
+               return $components[0];
+       }
+}
diff --git a/apps/dav/lib/Search/EventsSearchProvider.php b/apps/dav/lib/Search/EventsSearchProvider.php
new file mode 100644 (file)
index 0000000..43fc4f6
--- /dev/null
@@ -0,0 +1,231 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCP\IUser;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use Sabre\VObject\Component;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\Property;
+
+/**
+ * Class EventsSearchProvider
+ *
+ * @package OCA\DAV\Search
+ */
+class EventsSearchProvider extends ACalendarSearchProvider {
+
+       /**
+        * @var string[]
+        */
+       private static $searchProperties = [
+               'SUMMARY',
+               'LOCATION',
+               'DESCRIPTION',
+               'ATTENDEE',
+               'ORGANIZER',
+               'CATEGORIES',
+       ];
+
+       /**
+        * @var string[]
+        */
+       private static $searchParameters = [
+               'ATTENDEE' => ['CN'],
+               'ORGANIZER' => ['CN'],
+       ];
+
+       /**
+        * @var string
+        */
+       private static $componentType = 'VEVENT';
+
+       /**
+        * @inheritDoc
+        */
+       public function getId(): string {
+               return 'calendar-dav';
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function getName(): string {
+               return $this->l10n->t('Events');
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function search(IUser $user,
+                                                  ISearchQuery $query): SearchResult {
+               if (!$this->appManager->isEnabledForUser('calendar', $user)) {
+                       return SearchResult::complete($this->getName(), []);
+               }
+
+               $principalUri = 'principals/users/' . $user->getUID();
+               $calendarsById = $this->getSortedCalendars($principalUri);
+               $subscriptionsById = $this->getSortedSubscriptions($principalUri);
+
+               $searchResults = $this->backend->searchPrincipalUri(
+                       $principalUri,
+                       $query->getTerm(),
+                       [self::$componentType],
+                       self::$searchProperties,
+                       self::$searchParameters,
+                       [
+                               'limit' => $query->getLimit(),
+                               'offset' => $query->getCursor(),
+                       ]
+               );
+               $formattedResults = \array_map(function (array $eventRow) use ($calendarsById, $subscriptionsById):EventsSearchResultEntry {
+                       $component = $this->getPrimaryComponent($eventRow['calendardata'], self::$componentType);
+                       $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled event'));
+                       $subline = $this->generateSubline($component);
+
+                       if ($eventRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) {
+                               $calendar = $calendarsById[$eventRow['calendarid']];
+                       } else {
+                               $calendar = $subscriptionsById[$eventRow['calendarid']];
+                       }
+                       $resourceUrl = $this->getDeepLinkToCalendarApp($calendar['principaluri'], $calendar['uri'], $eventRow['uri']);
+
+                       return new EventsSearchResultEntry('', $title, $subline, $resourceUrl, 'icon-calendar-dark', false);
+               }, $searchResults);
+
+               return SearchResult::paginated(
+                       $this->getName(),
+                       $formattedResults,
+                       $query->getCursor() + count($formattedResults)
+               );
+       }
+
+       /**
+        * @param string $principalUri
+        * @param string $calendarUri
+        * @param string $calendarObjectUri
+        * @return string
+        */
+       protected function getDeepLinkToCalendarApp(string $principalUri,
+                                                                                               string $calendarUri,
+                                                                                               string $calendarObjectUri): string {
+               $davUrl = $this->getDavUrlForCalendarObject($principalUri, $calendarUri, $calendarObjectUri);
+               // This route will automatically figure out what recurrence-id to open
+               return $this->urlGenerator->getAbsoluteURL(
+                       $this->urlGenerator->linkToRoute('calendar.view.index')
+                       . 'edit/'
+                       . base64_encode($davUrl)
+               );
+       }
+
+       /**
+        * @param string $principalUri
+        * @param string $calendarUri
+        * @param string $calendarObjectUri
+        * @return string
+        */
+       protected function getDavUrlForCalendarObject(string $principalUri,
+                                                                                                 string $calendarUri,
+                                                                                                 string $calendarObjectUri): string {
+               [,, $principalId] = explode('/', $principalUri, 3);
+
+               return $this->urlGenerator->linkTo('', 'remote.php') . '/dav/calendars/'
+                       . $principalId . '/'
+                       . $calendarUri . '/'
+                       . $calendarObjectUri;
+       }
+
+       /**
+        * @param Component $eventComponent
+        * @return string
+        */
+       protected function generateSubline(Component $eventComponent): string {
+               $dtStart = $eventComponent->DTSTART;
+               $dtEnd = $this->getDTEndForEvent($eventComponent);
+               $isAllDayEvent = $dtStart instanceof Property\ICalendar\Date;
+               $startDateTime = new \DateTime($dtStart->getDateTime()->format(\DateTime::ATOM));
+               $endDateTime = new \DateTime($dtEnd->getDateTime()->format(\DateTime::ATOM));
+
+               if ($isAllDayEvent) {
+                       $endDateTime->modify('-1 day');
+                       if ($this->isDayEqual($startDateTime, $endDateTime)) {
+                               return $this->l10n->l('date', $startDateTime, ['width' => 'medium']);
+                       }
+
+                       $formattedStart = $this->l10n->l('date', $startDateTime, ['width' => 'medium']);
+                       $formattedEnd = $this->l10n->l('date', $endDateTime, ['width' => 'medium']);
+                       return "$formattedStart - $formattedEnd";
+               }
+
+               $formattedStartDate = $this->l10n->l('date', $startDateTime, ['width' => 'medium']);
+               $formattedEndDate = $this->l10n->l('date', $endDateTime, ['width' => 'medium']);
+               $formattedStartTime = $this->l10n->l('time', $startDateTime, ['width' => 'short']);
+               $formattedEndTime = $this->l10n->l('time', $endDateTime, ['width' => 'short']);
+
+               if ($this->isDayEqual($startDateTime, $endDateTime)) {
+                       return "$formattedStartDate $formattedStartTime - $formattedEndTime";
+               }
+
+               return "$formattedStartDate $formattedStartTime - $formattedEndDate $formattedEndTime";
+       }
+
+       /**
+        * @param Component $eventComponent
+        * @return Property
+        */
+       protected function getDTEndForEvent(Component $eventComponent):Property {
+               if (isset($eventComponent->DTEND)) {
+                       $end = $eventComponent->DTEND;
+               } elseif (isset($eventComponent->DURATION)) {
+                       $isFloating = $eventComponent->DTSTART->isFloating();
+                       $end = clone $eventComponent->DTSTART;
+                       $endDateTime = $end->getDateTime();
+                       $endDateTime = $endDateTime->add(DateTimeParser::parse($eventComponent->DURATION->getValue()));
+                       $end->setDateTime($endDateTime, $isFloating);
+               } elseif (!$eventComponent->DTSTART->hasTime()) {
+                       $isFloating = $eventComponent->DTSTART->isFloating();
+                       $end = clone $eventComponent->DTSTART;
+                       $endDateTime = $end->getDateTime();
+                       $endDateTime = $endDateTime->modify('+1 day');
+                       $end->setDateTime($endDateTime, $isFloating);
+               } else {
+                       $end = clone $eventComponent->DTSTART;
+               }
+
+               return $end;
+       }
+
+       /**
+        * @param \DateTime $dtStart
+        * @param \DateTime $dtEnd
+        * @return bool
+        */
+       protected function isDayEqual(\DateTime $dtStart,
+                                                                 \DateTime $dtEnd) {
+               return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
+       }
+}
diff --git a/apps/dav/lib/Search/EventsSearchResultEntry.php b/apps/dav/lib/Search/EventsSearchResultEntry.php
new file mode 100644 (file)
index 0000000..f70f10a
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCP\Search\ASearchResultEntry;
+
+class EventsSearchResultEntry extends ASearchResultEntry {
+}
diff --git a/apps/dav/lib/Search/TasksSearchProvider.php b/apps/dav/lib/Search/TasksSearchProvider.php
new file mode 100644 (file)
index 0000000..eee4694
--- /dev/null
@@ -0,0 +1,160 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCP\IUser;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use Sabre\VObject\Component;
+
+/**
+ * Class TasksSearchProvider
+ *
+ * @package OCA\DAV\Search
+ */
+class TasksSearchProvider extends ACalendarSearchProvider {
+
+       /**
+        * @var string[]
+        */
+       private static $searchProperties = [
+               'SUMMARY',
+               'DESCRIPTION',
+               'CATEGORIES',
+       ];
+
+       /**
+        * @var string[]
+        */
+       private static $searchParameters = [];
+
+       /**
+        * @var string
+        */
+       private static $componentType = 'VTODO';
+
+       /**
+        * @inheritDoc
+        */
+       public function getId(): string {
+               return 'tasks-dav';
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function getName(): string {
+               return $this->l10n->t('Tasks');
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function search(IUser $user,
+                                                  ISearchQuery $query): SearchResult {
+               if (!$this->appManager->isEnabledForUser('tasks', $user)) {
+                       return SearchResult::complete($this->getName(), []);
+               }
+
+               $principalUri = 'principals/users/' . $user->getUID();
+               $calendarsById = $this->getSortedCalendars($principalUri);
+               $subscriptionsById = $this->getSortedSubscriptions($principalUri);
+
+               $searchResults = $this->backend->searchPrincipalUri(
+                       $principalUri,
+                       $query->getTerm(),
+                       [self::$componentType],
+                       self::$searchProperties,
+                       self::$searchParameters,
+                       [
+                               'limit' => $query->getLimit(),
+                               'offset' => $query->getCursor(),
+                       ]
+               );
+               $formattedResults = \array_map(function (array $taskRow) use ($calendarsById, $subscriptionsById):TasksSearchResultEntry {
+                       $component = $this->getPrimaryComponent($taskRow['calendardata'], self::$componentType);
+                       $title = (string)($component->SUMMARY ?? $this->l10n->t('Untitled task'));
+                       $subline = $this->generateSubline($component);
+
+                       if ($taskRow['calendartype'] === CalDavBackend::CALENDAR_TYPE_CALENDAR) {
+                               $calendar = $calendarsById[$taskRow['calendarid']];
+                       } else {
+                               $calendar = $subscriptionsById[$taskRow['calendarid']];
+                       }
+                       $resourceUrl = $this->getDeepLinkToTasksApp($calendar['uri'], $taskRow['uri']);
+
+                       return new TasksSearchResultEntry('', $title, $subline, $resourceUrl, 'icon-checkmark', false);
+               }, $searchResults);
+
+               return SearchResult::paginated(
+                       $this->getName(),
+                       $formattedResults,
+                       $query->getCursor() + count($formattedResults)
+               );
+       }
+
+       /**
+        * @param string $calendarUri
+        * @param string $taskUri
+        * @return string
+        */
+       protected function getDeepLinkToTasksApp(string $calendarUri,
+                                                                                        string $taskUri): string {
+               return $this->urlGenerator->getAbsoluteURL(
+                       $this->urlGenerator->linkToRoute('tasks.page.index')
+                       . '#/calendars/'
+                       . $calendarUri
+                       . '/tasks/'
+                       . $taskUri
+               );
+       }
+
+       /**
+        * @param Component $taskComponent
+        * @return string
+        */
+       protected function generateSubline(Component $taskComponent): string {
+               if ($taskComponent->COMPLETED) {
+                       $completedDateTime = new \DateTime($taskComponent->COMPLETED->getDateTime()->format(\DateTime::ATOM));
+                       $formattedDate = $this->l10n->l('date', $completedDateTime, ['width' => 'medium']);
+                       return $this->l10n->t('Completed on %s', [$formattedDate]);
+               }
+
+               if ($taskComponent->DUE) {
+                       $dueDateTime = new \DateTime($taskComponent->DUE->getDateTime()->format(\DateTime::ATOM));
+                       $formattedDate = $this->l10n->l('date', $dueDateTime, ['width' => 'medium']);
+
+                       if ($taskComponent->DUE->hasTime()) {
+                               $formattedTime =  $this->l10n->l('time', $dueDateTime, ['width' => 'short']);
+                               return $this->l10n->t('Due on %s by %s', [$formattedDate, $formattedTime]);
+                       }
+
+                       return $this->l10n->t('Due on %s', [$formattedDate]);
+               }
+
+               return '';
+       }
+}
diff --git a/apps/dav/lib/Search/TasksSearchResultEntry.php b/apps/dav/lib/Search/TasksSearchResultEntry.php
new file mode 100644 (file)
index 0000000..ec58ba8
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Search;
+
+use OCP\Search\ASearchResultEntry;
+
+class TasksSearchResultEntry extends ASearchResultEntry {
+}
index 4c6c854905537ffc47b2f9b1c5ac8dbf0ebfbde5..79da92148affea45e91db3c4be617a34801cc2c4 100644 (file)
@@ -121,7 +121,12 @@ abstract class AbstractCalDavBackend extends TestCase {
                $this->principal->expects($this->any())->method('getGroupMembership')
                        ->withAnyParameters()
                        ->willReturn([self::UNIT_TEST_GROUP, self::UNIT_TEST_GROUP2]);
-               $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
+               $this->cleanupForPrincipal(self::UNIT_TEST_USER);
+               $this->cleanupForPrincipal(self::UNIT_TEST_USER1);
+       }
+
+       private function cleanupForPrincipal($principal): void {
+               $calendars = $this->backend->getCalendarsForUser($principal);
                foreach ($calendars as $calendar) {
                        $this->dispatcher->expects($this->at(0))
                                ->method('dispatch')
@@ -129,7 +134,7 @@ abstract class AbstractCalDavBackend extends TestCase {
 
                        $this->backend->deleteCalendar($calendar['id']);
                }
-               $subscriptions = $this->backend->getSubscriptionsForUser(self::UNIT_TEST_USER);
+               $subscriptions = $this->backend->getSubscriptionsForUser($principal);
                foreach ($subscriptions as $subscription) {
                        $this->backend->deleteSubscription($subscription['id']);
                }
index bd6a8856d51983ed157f70fc04fcfffa6c423fea..4e343340f25fc2e5ec5720f3f8d26ed950bcb2b1 100644 (file)
@@ -605,21 +605,21 @@ DTSTART;TZID=Europe/Warsaw:20170325T150000
 DTEND;TZID=Europe/Warsaw:20170325T160000
 TRANSP:OPAQUE
 DESCRIPTION:Magiczna treść uzyskana za pomocą magicznego proszku.\n\nę
- żźćńłóÓŻŹĆŁĘ€śśśŚŚ\n               \,\,))))))))\;\,\n  
+ żźćńłóÓŻŹĆŁĘ€śśśŚŚ\n               \,\,))))))))\;\,\n
            __))))))))))))))\,\n \\|/       -\\(((((''''((((((((.\n -*-==///
- ///((''  .     `))))))\,\n /|\\      ))| o    \;-.    '(((((              
-                     \,(\,\n          ( `|    /  )    \;))))'              
+ ///((''  .     `))))))\,\n /|\\      ))| o    \;-.    '(((((
+                     \,(\,\n          ( `|    /  )    \;))))'
                   \,_))^\;(~\n             |   |   |   \,))((((_     _____-
  -----~~~-.        %\,\;(\;(>'\;'~\n             o_)\;   \;    )))(((` ~---
  ~  `::           \\      %%~~)(v\;(`('~\n                   \;    ''''````
-          `:       `:::|\\\,__\,%%    )\;`'\; ~\n                  |   _   
-              )     /      `:|`----'     `-'\n            ______/\\/~    | 
+          `:       `:::|\\\,__\,%%    )\;`'\; ~\n                  |   _
+              )     /      `:|`----'     `-'\n            ______/\\/~    |
                  /        /\n          /~\;\;.____/\;\;'  /          ___--\
- ,-(   `\;\;\;/\n         / //  _\;______\;'------~~~~~    /\;\;/\\    /\n 
-        //  | |                        / \;   \\\;\;\,\\\n       (<_  | \; 
-                      /'\,/-----'  _>\n        \\_| ||_                    
-  //~\;~~~~~~~~~\n            `\\_|                   (\,~~  -Tua Xiong\n  
-                                   \\~\\\n                                 
+ ,-(   `\;\;\;/\n         / //  _\;______\;'------~~~~~    /\;\;/\\    /\n
+        //  | |                        / \;   \\\;\;\,\\\n       (<_  | \;
+                      /'\,/-----'  _>\n        \\_| ||_
+  //~\;~~~~~~~~~\n            `\\_|                   (\,~~  -Tua Xiong\n
+                                   \\~\\\n
      ~~\n\n
 SEQUENCE:1
 X-MOZ-GENERATION:1
@@ -999,4 +999,282 @@ EOD;
                $this->assertEquals($calendarInfoUser['id'], $calendarInfoUser1['id']);
                $this->assertEquals($calendarInfoUser['uri'], $calendarInfoUser1['uri']);
        }
+
+       public function testSearchPrincipal(): void {
+               $myPublic = <<<EOD
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//dmfs.org//mimedir.icalendar//EN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Berlin:20160419T130000
+SUMMARY:My Test (public)
+CLASS:PUBLIC
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+DTEND;TZID=Europe/Berlin:20160419T140000
+LAST-MODIFIED:20160419T074202Z
+DTSTAMP:20160419T074202Z
+CREATED:20160419T074202Z
+UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-1
+END:VEVENT
+END:VCALENDAR
+EOD;
+               $myPrivate = <<<EOD
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//dmfs.org//mimedir.icalendar//EN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Berlin:20160419T130000
+SUMMARY:My Test (private)
+CLASS:PRIVATE
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+DTEND;TZID=Europe/Berlin:20160419T140000
+LAST-MODIFIED:20160419T074202Z
+DTSTAMP:20160419T074202Z
+CREATED:20160419T074202Z
+UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-2
+END:VEVENT
+END:VCALENDAR
+EOD;
+               $myConfidential = <<<EOD
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//dmfs.org//mimedir.icalendar//EN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Berlin:20160419T130000
+SUMMARY:My Test (confidential)
+CLASS:CONFIDENTIAL
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+DTEND;TZID=Europe/Berlin:20160419T140000
+LAST-MODIFIED:20160419T074202Z
+DTSTAMP:20160419T074202Z
+CREATED:20160419T074202Z
+UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-3
+END:VEVENT
+END:VCALENDAR
+EOD;
+
+               $sharerPublic = <<<EOD
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//dmfs.org//mimedir.icalendar//EN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Berlin:20160419T130000
+SUMMARY:Sharer Test (public)
+CLASS:PUBLIC
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+DTEND;TZID=Europe/Berlin:20160419T140000
+LAST-MODIFIED:20160419T074202Z
+DTSTAMP:20160419T074202Z
+CREATED:20160419T074202Z
+UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-4
+END:VEVENT
+END:VCALENDAR
+EOD;
+               $sharerPrivate = <<<EOD
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//dmfs.org//mimedir.icalendar//EN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Berlin:20160419T130000
+SUMMARY:Sharer Test (private)
+CLASS:PRIVATE
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+DTEND;TZID=Europe/Berlin:20160419T140000
+LAST-MODIFIED:20160419T074202Z
+DTSTAMP:20160419T074202Z
+CREATED:20160419T074202Z
+UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-5
+END:VEVENT
+END:VCALENDAR
+EOD;
+               $sharerConfidential = <<<EOD
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//dmfs.org//mimedir.icalendar//EN
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-LIC-LOCATION:Europe/Berlin
+BEGIN:DAYLIGHT
+TZOFFSETFROM:+0100
+TZOFFSETTO:+0200
+TZNAME:CEST
+DTSTART:19700329T020000
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
+END:DAYLIGHT
+BEGIN:STANDARD
+TZOFFSETFROM:+0200
+TZOFFSETTO:+0100
+TZNAME:CET
+DTSTART:19701025T030000
+RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=Europe/Berlin:20160419T130000
+SUMMARY:Sharer Test (confidential)
+CLASS:CONFIDENTIAL
+TRANSP:OPAQUE
+STATUS:CONFIRMED
+DTEND;TZID=Europe/Berlin:20160419T140000
+LAST-MODIFIED:20160419T074202Z
+DTSTAMP:20160419T074202Z
+CREATED:20160419T074202Z
+UID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310-6
+END:VEVENT
+END:VCALENDAR
+EOD;
+
+               $l10n = $this->createMock(IL10N::class);
+               $l10n
+                       ->expects($this->any())
+                       ->method('t')
+                       ->willReturnCallback(function ($text, $parameters = []) {
+                               return vsprintf($text, $parameters);
+                       });
+               $config = $this->createMock(IConfig::class);
+               $this->userManager->expects($this->any())
+                       ->method('userExists')
+                       ->willReturn(true);
+               $this->groupManager->expects($this->any())
+                       ->method('groupExists')
+                       ->willReturn(true);
+
+               $me = self::UNIT_TEST_USER;
+               $sharer = self::UNIT_TEST_USER1;
+               $this->backend->createCalendar($me, 'calendar-uri-me', []);
+               $this->backend->createCalendar($sharer, 'calendar-uri-sharer', []);
+
+               $myCalendars = $this->backend->getCalendarsForUser($me);
+               $this->assertCount(1, $myCalendars);
+
+               $sharerCalendars = $this->backend->getCalendarsForUser($sharer);
+               $this->assertCount(1, $sharerCalendars);
+               $sharerCalendar = new Calendar($this->backend, $sharerCalendars[0], $l10n, $config);
+               $this->backend->updateShares($sharerCalendar, [
+                       [
+                               'href' => 'principal:' . $me,
+                               'readOnly' => false,
+                       ],
+               ], []);
+
+               $this->assertCount(2, $this->backend->getCalendarsForUser($me));
+
+               $this->backend->createCalendarObject($myCalendars[0]['id'], 'event0.ics', $myPublic);
+               $this->backend->createCalendarObject($myCalendars[0]['id'], 'event1.ics', $myPrivate);
+               $this->backend->createCalendarObject($myCalendars[0]['id'], 'event2.ics', $myConfidential);
+
+               $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event3.ics', $sharerPublic);
+               $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event4.ics', $sharerPrivate);
+               $this->backend->createCalendarObject($sharerCalendars[0]['id'], 'event5.ics', $sharerConfidential);
+
+               $mySearchResults = $this->backend->searchPrincipalUri($me, 'Test', ['VEVENT'], ['SUMMARY'], []);
+               $sharerSearchResults = $this->backend->searchPrincipalUri($sharer, 'Test', ['VEVENT'], ['SUMMARY'], []);
+
+               $this->assertCount(4, $mySearchResults);
+               $this->assertCount(3, $sharerSearchResults);
+
+               $this->assertEquals($myPublic, $mySearchResults[0]['calendardata']);
+               $this->assertEquals($myPrivate, $mySearchResults[1]['calendardata']);
+               $this->assertEquals($myConfidential, $mySearchResults[2]['calendardata']);
+               $this->assertEquals($sharerPublic, $mySearchResults[3]['calendardata']);
+
+               $this->assertEquals($sharerPublic, $sharerSearchResults[0]['calendardata']);
+               $this->assertEquals($sharerPrivate, $sharerSearchResults[1]['calendardata']);
+               $this->assertEquals($sharerConfidential, $sharerSearchResults[2]['calendardata']);
+       }
 }
index d89d8dd169069afe1ca48b816cb29513b0887341..e0cfe3245bae0a97ed8834e801ca50eab312b334 100644 (file)
@@ -224,7 +224,7 @@ class ContactsSearchProviderTest extends TestCase {
                $this->assertEquals('icon-contacts-dark', $result0Data['iconClass']);
                $this->assertTrue($result0Data['rounded']);
 
-               $this->assertInstanceOf(ContactsSearchResultEntry::class, $result0);
+               $this->assertInstanceOf(ContactsSearchResultEntry::class, $result1);
                $this->assertEquals('absolute-thumbnail-url?photo', $result1Data['thumbnailUrl']);
                $this->assertEquals('FN of Test2', $result1Data['title']);
                $this->assertEquals('subline', $result1Data['subline']);
diff --git a/apps/dav/tests/unit/Search/EventsSearchProviderTest.php b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php
new file mode 100644 (file)
index 0000000..f0d6329
--- /dev/null
@@ -0,0 +1,473 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Tests\unit\Search;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\Search\EventsSearchProvider;
+use OCA\DAV\Search\EventsSearchResultEntry;
+use OCP\App\IAppManager;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use Sabre\VObject\Reader;
+use Test\TestCase;
+
+class EventsSearchProviderTest extends TestCase {
+
+       /** @var IAppManager|\PHPUnit\Framework\MockObject\MockObject */
+       private $appManager;
+
+       /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
+       private $l10n;
+
+       /** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */
+       private $urlGenerator;
+
+       /** @var CalDavBackend|\PHPUnit\Framework\MockObject\MockObject */
+       private $backend;
+
+       /** @var EventsSearchProvider */
+       private $provider;
+
+       // NO SUMMARY
+       private $vEvent0 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20161004T144433Z'.PHP_EOL.
+               'UID:85560E76-1B0D-47E1-A735-21625767FCA4'.PHP_EOL.
+               'DTEND;VALUE=DATE:20161008'.PHP_EOL.
+               'TRANSP:TRANSPARENT'.PHP_EOL.
+               'DTSTART;VALUE=DATE:20161005'.PHP_EOL.
+               'DTSTAMP:20161004T144437Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // TIMED SAME DAY
+       private $vEvent1 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Tests//'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VTIMEZONE'.PHP_EOL.
+               'TZID:Europe/Berlin'.PHP_EOL.
+               'BEGIN:DAYLIGHT'.PHP_EOL.
+               'TZOFFSETFROM:+0100'.PHP_EOL.
+               'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU'.PHP_EOL.
+               'DTSTART:19810329T020000'.PHP_EOL.
+               'TZNAME:GMT+2'.PHP_EOL.
+               'TZOFFSETTO:+0200'.PHP_EOL.
+               'END:DAYLIGHT'.PHP_EOL.
+               'BEGIN:STANDARD'.PHP_EOL.
+               'TZOFFSETFROM:+0200'.PHP_EOL.
+               'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU'.PHP_EOL.
+               'DTSTART:19961027T030000'.PHP_EOL.
+               'TZNAME:GMT+1'.PHP_EOL.
+               'TZOFFSETTO:+0100'.PHP_EOL.
+               'END:STANDARD'.PHP_EOL.
+               'END:VTIMEZONE'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20160809T163629Z'.PHP_EOL.
+               'UID:0AD16F58-01B3-463B-A215-FD09FC729A02'.PHP_EOL.
+               'DTEND;TZID=Europe/Berlin:20160816T100000'.PHP_EOL.
+               'TRANSP:OPAQUE'.PHP_EOL.
+               'SUMMARY:Test Europe Berlin'.PHP_EOL.
+               'DTSTART;TZID=Europe/Berlin:20160816T090000'.PHP_EOL.
+               'DTSTAMP:20160809T163632Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // TIMED DIFFERENT DAY
+       private $vEvent2 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Tests//'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VTIMEZONE'.PHP_EOL.
+               'TZID:Europe/Berlin'.PHP_EOL.
+               'BEGIN:DAYLIGHT'.PHP_EOL.
+               'TZOFFSETFROM:+0100'.PHP_EOL.
+               'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU'.PHP_EOL.
+               'DTSTART:19810329T020000'.PHP_EOL.
+               'TZNAME:GMT+2'.PHP_EOL.
+               'TZOFFSETTO:+0200'.PHP_EOL.
+               'END:DAYLIGHT'.PHP_EOL.
+               'BEGIN:STANDARD'.PHP_EOL.
+               'TZOFFSETFROM:+0200'.PHP_EOL.
+               'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU'.PHP_EOL.
+               'DTSTART:19961027T030000'.PHP_EOL.
+               'TZNAME:GMT+1'.PHP_EOL.
+               'TZOFFSETTO:+0100'.PHP_EOL.
+               'END:STANDARD'.PHP_EOL.
+               'END:VTIMEZONE'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20160809T163629Z'.PHP_EOL.
+               'UID:0AD16F58-01B3-463B-A215-FD09FC729A02'.PHP_EOL.
+               'DTEND;TZID=Europe/Berlin:20160817T100000'.PHP_EOL.
+               'TRANSP:OPAQUE'.PHP_EOL.
+               'SUMMARY:Test Europe Berlin'.PHP_EOL.
+               'DTSTART;TZID=Europe/Berlin:20160816T090000'.PHP_EOL.
+               'DTSTAMP:20160809T163632Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // ALL-DAY ONE-DAY
+       private $vEvent3 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20161004T144433Z'.PHP_EOL.
+               'UID:85560E76-1B0D-47E1-A735-21625767FCA4'.PHP_EOL.
+               'DTEND;VALUE=DATE:20161006'.PHP_EOL.
+               'TRANSP:TRANSPARENT'.PHP_EOL.
+               'DTSTART;VALUE=DATE:20161005'.PHP_EOL.
+               'DTSTAMP:20161004T144437Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // ALL-DAY MULTIPLE DAYS
+       private $vEvent4 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20161004T144433Z'.PHP_EOL.
+               'UID:85560E76-1B0D-47E1-A735-21625767FCA4'.PHP_EOL.
+               'DTEND;VALUE=DATE:20161008'.PHP_EOL.
+               'TRANSP:TRANSPARENT'.PHP_EOL.
+               'DTSTART;VALUE=DATE:20161005'.PHP_EOL.
+               'DTSTAMP:20161004T144437Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // DURATION
+       private $vEvent5 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20161004T144433Z'.PHP_EOL.
+               'UID:85560E76-1B0D-47E1-A735-21625767FCA4'.PHP_EOL.
+               'DURATION:P5D'.PHP_EOL.
+               'TRANSP:TRANSPARENT'.PHP_EOL.
+               'DTSTART;VALUE=DATE:20161005'.PHP_EOL.
+               'DTSTAMP:20161004T144437Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // NO DTEND - DATE
+       private $vEvent6 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Apple Inc.//Mac OS X 10.11.6//EN'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20161004T144433Z'.PHP_EOL.
+               'UID:85560E76-1B0D-47E1-A735-21625767FCA4'.PHP_EOL.
+               'TRANSP:TRANSPARENT'.PHP_EOL.
+               'DTSTART;VALUE=DATE:20161005'.PHP_EOL.
+               'DTSTAMP:20161004T144437Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // NO DTEND - DATE-TIME
+       private $vEvent7 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'PRODID:-//Tests//'.PHP_EOL.
+               'CALSCALE:GREGORIAN'.PHP_EOL.
+               'BEGIN:VTIMEZONE'.PHP_EOL.
+               'TZID:Europe/Berlin'.PHP_EOL.
+               'BEGIN:DAYLIGHT'.PHP_EOL.
+               'TZOFFSETFROM:+0100'.PHP_EOL.
+               'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU'.PHP_EOL.
+               'DTSTART:19810329T020000'.PHP_EOL.
+               'TZNAME:GMT+2'.PHP_EOL.
+               'TZOFFSETTO:+0200'.PHP_EOL.
+               'END:DAYLIGHT'.PHP_EOL.
+               'BEGIN:STANDARD'.PHP_EOL.
+               'TZOFFSETFROM:+0200'.PHP_EOL.
+               'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU'.PHP_EOL.
+               'DTSTART:19961027T030000'.PHP_EOL.
+               'TZNAME:GMT+1'.PHP_EOL.
+               'TZOFFSETTO:+0100'.PHP_EOL.
+               'END:STANDARD'.PHP_EOL.
+               'END:VTIMEZONE'.PHP_EOL.
+               'BEGIN:VEVENT'.PHP_EOL.
+               'CREATED:20160809T163629Z'.PHP_EOL.
+               'UID:0AD16F58-01B3-463B-A215-FD09FC729A02'.PHP_EOL.
+               'TRANSP:OPAQUE'.PHP_EOL.
+               'SUMMARY:Test Europe Berlin'.PHP_EOL.
+               'DTSTART;TZID=Europe/Berlin:20160816T090000'.PHP_EOL.
+               'DTSTAMP:20160809T163632Z'.PHP_EOL.
+               'SEQUENCE:0'.PHP_EOL.
+               'END:VEVENT'.PHP_EOL.
+               'END:VCALENDAR';
+
+       protected function setUp(): void {
+               parent::setUp();
+
+               $this->appManager = $this->createMock(IAppManager::class);
+               $this->l10n = $this->createMock(IL10N::class);
+               $this->urlGenerator = $this->createMock(IURLGenerator::class);
+               $this->backend = $this->createMock(CalDavBackend::class);
+
+               $this->provider = new EventsSearchProvider(
+                       $this->appManager,
+                       $this->l10n,
+                       $this->urlGenerator,
+                       $this->backend
+               );
+       }
+
+       public function testGetId(): void {
+               $this->assertEquals('calendar-dav', $this->provider->getId());
+       }
+
+       public function testGetName(): void {
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->with('Events')
+                       ->willReturnArgument(0);
+
+               $this->assertEquals('Events', $this->provider->getName());
+       }
+
+       public function testSearchAppDisabled(): void {
+               $user = $this->createMock(IUser::class);
+               $query = $this->createMock(ISearchQuery::class);
+               $this->appManager->expects($this->once())
+                       ->method('isEnabledForUser')
+                       ->with('calendar', $user)
+                       ->willReturn(false);
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->willReturnArgument(0);
+               $this->backend->expects($this->never())
+                       ->method('getCalendarsForUser');
+               $this->backend->expects($this->never())
+                       ->method('getSubscriptionsForUser');
+               $this->backend->expects($this->never())
+                       ->method('searchPrincipalUri');
+
+               $actual = $this->provider->search($user, $query);
+               $data = $actual->jsonSerialize();
+               $this->assertInstanceOf(SearchResult::class, $actual);
+               $this->assertEquals('Events', $data['name']);
+               $this->assertEmpty($data['entries']);
+               $this->assertFalse($data['isPaginated']);
+               $this->assertNull($data['cursor']);
+       }
+
+       public function testSearch(): void {
+               $user = $this->createMock(IUser::class);
+               $user->method('getUID')->willReturn('john.doe');
+               $query = $this->createMock(ISearchQuery::class);
+               $query->method('getTerm')->willReturn('search term');
+               $query->method('getLimit')->willReturn(5);
+               $query->method('getCursor')->willReturn(20);
+               $this->appManager->expects($this->once())
+                       ->method('isEnabledForUser')
+                       ->with('calendar', $user)
+                       ->willReturn(true);
+               $this->l10n->method('t')->willReturnArgument(0);
+
+               $this->backend->expects($this->once())
+                       ->method('getCalendarsForUser')
+                       ->with('principals/users/john.doe')
+                       ->willReturn([
+                               [
+                                       'id' => 99,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'calendar-uri-99',
+                               ], [
+                                       'id' => 123,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'calendar-uri-123',
+                               ]
+                       ]);
+               $this->backend->expects($this->once())
+                       ->method('getSubscriptionsForUser')
+                       ->with('principals/users/john.doe')
+                       ->willReturn([
+                               [
+                                       'id' => 1337,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'subscription-uri-1337',
+                               ]
+                       ]);
+               $this->backend->expects($this->once())
+                       ->method('searchPrincipalUri')
+                       ->with('principals/users/john.doe', 'search term', ['VEVENT'],
+                               ['SUMMARY', 'LOCATION', 'DESCRIPTION', 'ATTENDEE', 'ORGANIZER', 'CATEGORIES'],
+                               ['ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN']],
+                               ['limit' => 5, 'offset' => 20])
+                       ->willReturn([
+                               [
+                                       'calendarid' => 99,
+                                       'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR,
+                                       'uri' => 'event0.ics',
+                                       'calendardata' => $this->vEvent0,
+                               ],
+                               [
+                                       'calendarid' => 123,
+                                       'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR,
+                                       'uri' => 'event1.ics',
+                                       'calendardata' => $this->vEvent1,
+                               ],
+                               [
+                                       'calendarid' => 1337,
+                                       'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION,
+                                       'uri' => 'event2.ics',
+                                       'calendardata' => $this->vEvent2,
+                               ]
+                       ]);
+
+               $provider = $this->getMockBuilder(EventsSearchProvider::class)
+                       ->setConstructorArgs([
+                               $this->appManager,
+                               $this->l10n,
+                               $this->urlGenerator,
+                               $this->backend,
+                       ])
+                       ->setMethods([
+                               'getDeepLinkToCalendarApp',
+                               'generateSubline',
+                       ])
+                       ->getMock();
+
+               $provider->expects($this->exactly(3))
+                       ->method('generateSubline')
+                       ->willReturn('subline');
+               $provider->expects($this->exactly(3))
+                       ->method('getDeepLinkToCalendarApp')
+                       ->withConsecutive(
+                               ['principals/users/john.doe', 'calendar-uri-99', 'event0.ics'],
+                               ['principals/users/john.doe', 'calendar-uri-123', 'event1.ics'],
+                               ['principals/users/john.doe', 'subscription-uri-1337', 'event2.ics']
+                       )
+                       ->willReturn('deep-link-to-calendar');
+
+               $actual = $provider->search($user, $query);
+               $data = $actual->jsonSerialize();
+               $this->assertInstanceOf(SearchResult::class, $actual);
+               $this->assertEquals('Events', $data['name']);
+               $this->assertCount(3, $data['entries']);
+               $this->assertTrue($data['isPaginated']);
+               $this->assertEquals(23, $data['cursor']);
+
+               $result0 = $data['entries'][0];
+               $result0Data = $result0->jsonSerialize();
+               $result1 = $data['entries'][1];
+               $result1Data = $result1->jsonSerialize();
+               $result2 = $data['entries'][2];
+               $result2Data = $result2->jsonSerialize();
+
+               $this->assertInstanceOf(EventsSearchResultEntry::class, $result0);
+               $this->assertEmpty($result0Data['thumbnailUrl']);
+               $this->assertEquals('Untitled event', $result0Data['title']);
+               $this->assertEquals('subline', $result0Data['subline']);
+               $this->assertEquals('deep-link-to-calendar', $result0Data['resourceUrl']);
+               $this->assertEquals('icon-calendar-dark', $result0Data['iconClass']);
+               $this->assertFalse($result0Data['rounded']);
+
+               $this->assertInstanceOf(EventsSearchResultEntry::class, $result1);
+               $this->assertEmpty($result1Data['thumbnailUrl']);
+               $this->assertEquals('Test Europe Berlin', $result1Data['title']);
+               $this->assertEquals('subline', $result1Data['subline']);
+               $this->assertEquals('deep-link-to-calendar', $result1Data['resourceUrl']);
+               $this->assertEquals('icon-calendar-dark', $result1Data['iconClass']);
+               $this->assertFalse($result1Data['rounded']);
+
+               $this->assertInstanceOf(EventsSearchResultEntry::class, $result2);
+               $this->assertEmpty($result2Data['thumbnailUrl']);
+               $this->assertEquals('Test Europe Berlin', $result2Data['title']);
+               $this->assertEquals('subline', $result2Data['subline']);
+               $this->assertEquals('deep-link-to-calendar', $result2Data['resourceUrl']);
+               $this->assertEquals('icon-calendar-dark', $result2Data['iconClass']);
+               $this->assertFalse($result2Data['rounded']);
+       }
+
+       public function testGetDeepLinkToCalendarApp(): void {
+               $this->urlGenerator->expects($this->at(0))
+                       ->method('linkTo')
+                       ->with('', 'remote.php')
+                       ->willReturn('link-to-remote.php');
+               $this->urlGenerator->expects($this->at(1))
+                       ->method('linkToRoute')
+                       ->with('calendar.view.index')
+                       ->willReturn('link-to-route-calendar/');
+               $this->urlGenerator->expects($this->at(2))
+                       ->method('getAbsoluteURL')
+                       ->with('link-to-route-calendar/edit/bGluay10by1yZW1vdGUucGhwL2Rhdi9jYWxlbmRhcnMvam9obi5kb2UvZm9vL2Jhci5pY3M=')
+                       ->willReturn('absolute-url-to-route');
+
+               $actual = self::invokePrivate($this->provider, 'getDeepLinkToCalendarApp', ['principals/users/john.doe', 'foo', 'bar.ics']);
+
+               $this->assertEquals('absolute-url-to-route', $actual);
+       }
+
+       /**
+        * @param string $ics
+        * @param string $expectedSubline
+        *
+        * @dataProvider generateSublineDataProvider
+        */
+       public function testGenerateSubline(string $ics, string $expectedSubline): void {
+               $vCalendar = Reader::read($ics, Reader::OPTION_FORGIVING);
+               $eventComponent = $vCalendar->VEVENT;
+
+               $this->l10n->method('l')
+                       ->willReturnCallback(static function (string $type, \DateTime $date, $_):string {
+                               if ($type === 'time') {
+                                       return $date->format('H:i');
+                               }
+
+                               return $date->format('m-d');
+                       });
+
+               $actual = self::invokePrivate($this->provider, 'generateSubline', [$eventComponent]);
+               $this->assertEquals($expectedSubline, $actual);
+       }
+
+       public function generateSublineDataProvider(): array {
+               return [
+                       [$this->vEvent1, '08-16 09:00 - 10:00'],
+                       [$this->vEvent2, '08-16 09:00 - 08-17 10:00'],
+                       [$this->vEvent3, '10-05'],
+                       [$this->vEvent4, '10-05 - 10-07'],
+                       [$this->vEvent5, '10-05 - 10-09'],
+                       [$this->vEvent6, '10-05'],
+                       [$this->vEvent7, '08-16 09:00 - 09:00'],
+               ];
+       }
+}
diff --git a/apps/dav/tests/unit/Search/TasksSearchProviderTest.php b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php
new file mode 100644 (file)
index 0000000..30f5727
--- /dev/null
@@ -0,0 +1,344 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, Georg Ehrke
+ *
+ * @author Georg Ehrke <oc.list@georgehrke.com>
+ *
+ * @license AGPL-3.0
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+namespace OCA\DAV\Tests\unit\Search;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\Search\TasksSearchProvider;
+use OCA\DAV\Search\TasksSearchResultEntry;
+use OCP\App\IAppManager;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\Search\ISearchQuery;
+use OCP\Search\SearchResult;
+use Sabre\VObject\Reader;
+use Test\TestCase;
+
+class TasksSearchProviderTest extends TestCase {
+
+       /** @var IAppManager|\PHPUnit\Framework\MockObject\MockObject */
+       private $appManager;
+
+       /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */
+       private $l10n;
+
+       /** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */
+       private $urlGenerator;
+
+       /** @var CalDavBackend|\PHPUnit\Framework\MockObject\MockObject */
+       private $backend;
+
+       /** @var TasksSearchProvider */
+       private $provider;
+
+       // NO DUE NOR COMPLETED NOR SUMMARY
+       private $vTodo0 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'PRODID:TEST'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'BEGIN:VTODO'.PHP_EOL.
+               'UID:20070313T123432Z-456553@example.com'.PHP_EOL.
+               'DTSTAMP:20070313T123432Z'.PHP_EOL.
+               'STATUS:NEEDS-ACTION'.PHP_EOL.
+               'END:VTODO'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // DUE AND COMPLETED
+       private $vTodo1 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'PRODID:TEST'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'BEGIN:VTODO'.PHP_EOL.
+               'UID:20070313T123432Z-456553@example.com'.PHP_EOL.
+               'DTSTAMP:20070313T123432Z'.PHP_EOL.
+               'COMPLETED:20070707T100000Z'.PHP_EOL.
+               'DUE;VALUE=DATE:20070501'.PHP_EOL.
+               'SUMMARY:Task title'.PHP_EOL.
+               'STATUS:NEEDS-ACTION'.PHP_EOL.
+               'END:VTODO'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // COMPLETED ONLY
+       private $vTodo2 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'PRODID:TEST'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'BEGIN:VTODO'.PHP_EOL.
+               'UID:20070313T123432Z-456553@example.com'.PHP_EOL.
+               'DTSTAMP:20070313T123432Z'.PHP_EOL.
+               'COMPLETED:20070707T100000Z'.PHP_EOL.
+               'SUMMARY:Task title'.PHP_EOL.
+               'STATUS:NEEDS-ACTION'.PHP_EOL.
+               'END:VTODO'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // DUE DATE
+       private $vTodo3 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'PRODID:TEST'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'BEGIN:VTODO'.PHP_EOL.
+               'UID:20070313T123432Z-456553@example.com'.PHP_EOL.
+               'DTSTAMP:20070313T123432Z'.PHP_EOL.
+               'DUE;VALUE=DATE:20070501'.PHP_EOL.
+               'SUMMARY:Task title'.PHP_EOL.
+               'STATUS:NEEDS-ACTION'.PHP_EOL.
+               'END:VTODO'.PHP_EOL.
+               'END:VCALENDAR';
+
+       // DUE DATETIME
+       private $vTodo4 = 'BEGIN:VCALENDAR'.PHP_EOL.
+               'PRODID:TEST'.PHP_EOL.
+               'VERSION:2.0'.PHP_EOL.
+               'BEGIN:VTODO'.PHP_EOL.
+               'UID:20070313T123432Z-456553@example.com'.PHP_EOL.
+               'DTSTAMP:20070313T123432Z'.PHP_EOL.
+               'DUE:20070709T130000Z'.PHP_EOL.
+               'SUMMARY:Task title'.PHP_EOL.
+               'STATUS:NEEDS-ACTION'.PHP_EOL.
+               'END:VTODO'.PHP_EOL.
+               'END:VCALENDAR';
+
+       protected function setUp(): void {
+               parent::setUp();
+
+               $this->appManager = $this->createMock(IAppManager::class);
+               $this->l10n = $this->createMock(IL10N::class);
+               $this->urlGenerator = $this->createMock(IURLGenerator::class);
+               $this->backend = $this->createMock(CalDavBackend::class);
+
+               $this->provider = new TasksSearchProvider(
+                       $this->appManager,
+                       $this->l10n,
+                       $this->urlGenerator,
+                       $this->backend
+               );
+       }
+
+       public function testGetId(): void {
+               $this->assertEquals('tasks-dav', $this->provider->getId());
+       }
+
+       public function testGetName(): void {
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->with('Tasks')
+                       ->willReturnArgument(0);
+
+               $this->assertEquals('Tasks', $this->provider->getName());
+       }
+
+       public function testSearchAppDisabled(): void {
+               $user = $this->createMock(IUser::class);
+               $query = $this->createMock(ISearchQuery::class);
+               $this->appManager->expects($this->once())
+                       ->method('isEnabledForUser')
+                       ->with('tasks', $user)
+                       ->willReturn(false);
+               $this->l10n->expects($this->exactly(1))
+                       ->method('t')
+                       ->willReturnArgument(0);
+               $this->backend->expects($this->never())
+                       ->method('getCalendarsForUser');
+               $this->backend->expects($this->never())
+                       ->method('getSubscriptionsForUser');
+               $this->backend->expects($this->never())
+                       ->method('searchPrincipalUri');
+
+               $actual = $this->provider->search($user, $query);
+               $data = $actual->jsonSerialize();
+               $this->assertInstanceOf(SearchResult::class, $actual);
+               $this->assertEquals('Tasks', $data['name']);
+               $this->assertEmpty($data['entries']);
+               $this->assertFalse($data['isPaginated']);
+               $this->assertNull($data['cursor']);
+       }
+
+       public function testSearch(): void {
+               $user = $this->createMock(IUser::class);
+               $user->method('getUID')->willReturn('john.doe');
+               $query = $this->createMock(ISearchQuery::class);
+               $query->method('getTerm')->willReturn('search term');
+               $query->method('getLimit')->willReturn(5);
+               $query->method('getCursor')->willReturn(20);
+               $this->appManager->expects($this->once())
+                       ->method('isEnabledForUser')
+                       ->with('tasks', $user)
+                       ->willReturn(true);
+               $this->l10n->method('t')->willReturnArgument(0);
+
+               $this->backend->expects($this->once())
+                       ->method('getCalendarsForUser')
+                       ->with('principals/users/john.doe')
+                       ->willReturn([
+                               [
+                                       'id' => 99,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'calendar-uri-99',
+                               ], [
+                                       'id' => 123,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'calendar-uri-123',
+                               ]
+                       ]);
+               $this->backend->expects($this->once())
+                       ->method('getSubscriptionsForUser')
+                       ->with('principals/users/john.doe')
+                       ->willReturn([
+                               [
+                                       'id' => 1337,
+                                       'principaluri' => 'principals/users/john.doe',
+                                       'uri' => 'subscription-uri-1337',
+                               ]
+                       ]);
+               $this->backend->expects($this->once())
+                       ->method('searchPrincipalUri')
+                       ->with('principals/users/john.doe', 'search term', ['VTODO'],
+                               ['SUMMARY', 'DESCRIPTION', 'CATEGORIES'],
+                               [],
+                               ['limit' => 5, 'offset' => 20])
+                       ->willReturn([
+                               [
+                                       'calendarid' => 99,
+                                       'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR,
+                                       'uri' => 'todo0.ics',
+                                       'calendardata' => $this->vTodo0,
+                               ],
+                               [
+                                       'calendarid' => 123,
+                                       'calendartype' => CalDavBackend::CALENDAR_TYPE_CALENDAR,
+                                       'uri' => 'todo1.ics',
+                                       'calendardata' => $this->vTodo1,
+                               ],
+                               [
+                                       'calendarid' => 1337,
+                                       'calendartype' => CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION,
+                                       'uri' => 'todo2.ics',
+                                       'calendardata' => $this->vTodo2,
+                               ]
+                       ]);
+
+               $provider = $this->getMockBuilder(TasksSearchProvider::class)
+                       ->setConstructorArgs([
+                               $this->appManager,
+                               $this->l10n,
+                               $this->urlGenerator,
+                               $this->backend,
+                       ])
+                       ->setMethods([
+                               'getDeepLinkToTasksApp',
+                               'generateSubline',
+                       ])
+                       ->getMock();
+
+               $provider->expects($this->exactly(3))
+                       ->method('generateSubline')
+                       ->willReturn('subline');
+               $provider->expects($this->exactly(3))
+                       ->method('getDeepLinkToTasksApp')
+                       ->withConsecutive(
+                               ['calendar-uri-99', 'todo0.ics'],
+                               ['calendar-uri-123', 'todo1.ics'],
+                               ['subscription-uri-1337', 'todo2.ics']
+                       )
+                       ->willReturn('deep-link-to-tasks');
+
+               $actual = $provider->search($user, $query);
+               $data = $actual->jsonSerialize();
+               $this->assertInstanceOf(SearchResult::class, $actual);
+               $this->assertEquals('Tasks', $data['name']);
+               $this->assertCount(3, $data['entries']);
+               $this->assertTrue($data['isPaginated']);
+               $this->assertEquals(23, $data['cursor']);
+
+               $result0 = $data['entries'][0];
+               $result0Data = $result0->jsonSerialize();
+               $result1 = $data['entries'][1];
+               $result1Data = $result1->jsonSerialize();
+               $result2 = $data['entries'][2];
+               $result2Data = $result2->jsonSerialize();
+
+               $this->assertInstanceOf(TasksSearchResultEntry::class, $result0);
+               $this->assertEmpty($result0Data['thumbnailUrl']);
+               $this->assertEquals('Untitled task', $result0Data['title']);
+               $this->assertEquals('subline', $result0Data['subline']);
+               $this->assertEquals('deep-link-to-tasks', $result0Data['resourceUrl']);
+               $this->assertEquals('icon-checkmark', $result0Data['iconClass']);
+               $this->assertFalse($result0Data['rounded']);
+
+               $this->assertInstanceOf(TasksSearchResultEntry::class, $result1);
+               $this->assertEmpty($result1Data['thumbnailUrl']);
+               $this->assertEquals('Task title', $result1Data['title']);
+               $this->assertEquals('subline', $result1Data['subline']);
+               $this->assertEquals('deep-link-to-tasks', $result1Data['resourceUrl']);
+               $this->assertEquals('icon-checkmark', $result1Data['iconClass']);
+               $this->assertFalse($result1Data['rounded']);
+
+               $this->assertInstanceOf(TasksSearchResultEntry::class, $result2);
+               $this->assertEmpty($result2Data['thumbnailUrl']);
+               $this->assertEquals('Task title', $result2Data['title']);
+               $this->assertEquals('subline', $result2Data['subline']);
+               $this->assertEquals('deep-link-to-tasks', $result2Data['resourceUrl']);
+               $this->assertEquals('icon-checkmark', $result2Data['iconClass']);
+               $this->assertFalse($result2Data['rounded']);
+       }
+
+       public function testGetDeepLinkToTasksApp(): void {
+               $this->urlGenerator->expects($this->once())
+                       ->method('linkToRoute')
+                       ->with('tasks.page.index')
+                       ->willReturn('link-to-route-tasks.index');
+               $this->urlGenerator->expects($this->once())
+                       ->method('getAbsoluteURL')
+                       ->with('link-to-route-tasks.index#/calendars/uri-john.doe/tasks/task-uri.ics')
+                       ->willReturn('absolute-url-link-to-route-tasks.index#/calendars/uri-john.doe/tasks/task-uri.ics');
+
+               $actual = self::invokePrivate($this->provider, 'getDeepLinkToTasksApp', ['uri-john.doe', 'task-uri.ics']);
+               $this->assertEquals('absolute-url-link-to-route-tasks.index#/calendars/uri-john.doe/tasks/task-uri.ics', $actual);
+       }
+
+       /**
+        * @param string $ics
+        * @param string $expectedSubline
+        *
+        * @dataProvider generateSublineDataProvider
+        */
+       public function testGenerateSubline(string $ics, string $expectedSubline): void {
+               $vCalendar = Reader::read($ics, Reader::OPTION_FORGIVING);
+               $taskComponent = $vCalendar->VTODO;
+
+               $this->l10n->method('t')->willReturnArgument(0);
+               $this->l10n->method('l')->willReturnArgument('');
+
+               $actual = self::invokePrivate($this->provider, 'generateSubline', [$taskComponent]);
+               $this->assertEquals($expectedSubline, $actual);
+       }
+
+       public function generateSublineDataProvider(): array {
+               return [
+                       [$this->vTodo0, ''],
+                       [$this->vTodo1, 'Completed on %s'],
+                       [$this->vTodo2, 'Completed on %s'],
+                       [$this->vTodo3, 'Due on %s'],
+                       [$this->vTodo4, 'Due on %s by %s'],
+               ];
+       }
+}