From 900617e7d7260804ec89a03ca0201340d7585c8b Mon Sep 17 00:00:00 2001 From: Georg Ehrke Date: Mon, 27 Jul 2020 16:14:15 +0200 Subject: [PATCH] Add Event and Task Backends for Unified Search Signed-off-by: Georg Ehrke --- .../composer/composer/autoload_classmap.php | 5 + .../dav/composer/composer/autoload_static.php | 5 + apps/dav/lib/AppInfo/Application.php | 4 + apps/dav/lib/CalDAV/CalDavBackend.php | 118 +++++ .../lib/Search/ACalendarSearchProvider.php | 138 +++++ apps/dav/lib/Search/EventsSearchProvider.php | 231 +++++++++ .../lib/Search/EventsSearchResultEntry.php | 30 ++ apps/dav/lib/Search/TasksSearchProvider.php | 160 ++++++ .../dav/lib/Search/TasksSearchResultEntry.php | 30 ++ .../unit/CalDAV/AbstractCalDavBackend.php | 9 +- .../tests/unit/CalDAV/CalDavBackendTest.php | 298 ++++++++++- .../Search/ContactsSearchProviderTest.php | 2 +- .../unit/Search/EventsSearchProviderTest.php | 473 ++++++++++++++++++ .../unit/Search/TasksSearchProviderTest.php | 344 +++++++++++++ 14 files changed, 1834 insertions(+), 13 deletions(-) create mode 100644 apps/dav/lib/Search/ACalendarSearchProvider.php create mode 100644 apps/dav/lib/Search/EventsSearchProvider.php create mode 100644 apps/dav/lib/Search/EventsSearchResultEntry.php create mode 100644 apps/dav/lib/Search/TasksSearchProvider.php create mode 100644 apps/dav/lib/Search/TasksSearchResultEntry.php create mode 100644 apps/dav/tests/unit/Search/EventsSearchProviderTest.php create mode 100644 apps/dav/tests/unit/Search/TasksSearchProviderTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index bd63dee13b7..081f334a4f9 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -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', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index a664c86f5fd..3bfdb3b8628 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -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', diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index 6f2f7b29153..1bad3cb1eba 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -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 { diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index ddfb0a641e5..5cddf6e84b6 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -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 index 00000000000..56273fe17e4 --- /dev/null +++ b/apps/dav/lib/Search/ACalendarSearchProvider.php @@ -0,0 +1,138 @@ + + * + * @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 + * + */ +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 index 00000000000..43fc4f65dfc --- /dev/null +++ b/apps/dav/lib/Search/EventsSearchProvider.php @@ -0,0 +1,231 @@ + + * + * @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 + * + */ +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 index 00000000000..f70f10a6e75 --- /dev/null +++ b/apps/dav/lib/Search/EventsSearchResultEntry.php @@ -0,0 +1,30 @@ + + * + * @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 + * + */ +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 index 00000000000..eee4694f08f --- /dev/null +++ b/apps/dav/lib/Search/TasksSearchProvider.php @@ -0,0 +1,160 @@ + + * + * @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 + * + */ +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 index 00000000000..ec58ba80af9 --- /dev/null +++ b/apps/dav/lib/Search/TasksSearchResultEntry.php @@ -0,0 +1,30 @@ + + * + * @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 + * + */ +namespace OCA\DAV\Search; + +use OCP\Search\ASearchResultEntry; + +class TasksSearchResultEntry extends ASearchResultEntry { +} diff --git a/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php b/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php index 4c6c8549055..79da92148af 100644 --- a/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php +++ b/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php @@ -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']); } diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index bd6a8856d51..4e343340f25 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -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 = <<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']); + } } diff --git a/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php index d89d8dd1690..e0cfe3245ba 100644 --- a/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php +++ b/apps/dav/tests/unit/Search/ContactsSearchProviderTest.php @@ -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 index 00000000000..f0d6329db5c --- /dev/null +++ b/apps/dav/tests/unit/Search/EventsSearchProviderTest.php @@ -0,0 +1,473 @@ + + * + * @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 + * + */ +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 index 00000000000..30f57270e95 --- /dev/null +++ b/apps/dav/tests/unit/Search/TasksSearchProviderTest.php @@ -0,0 +1,344 @@ + + * + * @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 + * + */ +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'], + ]; + } +} -- 2.39.5