diff options
author | Morris Jobke <hey@morrisjobke.de> | 2017-04-28 23:38:04 -0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-04-28 23:38:04 -0300 |
commit | 2a773310dc58adcd299c1f7ae37e834cbae3b027 (patch) | |
tree | 44a449ba87fb65f0481bc6ded388debfdbcb5231 /apps/dav | |
parent | 424d62d630ee0e0d0ca2ae63f99b54f0f8c9c3b6 (diff) | |
parent | 0f8a9514de445c47a79d18a3ca075295398b4c60 (diff) | |
download | nextcloud-server-2a773310dc58adcd299c1f7ae37e834cbae3b027.tar.gz nextcloud-server-2a773310dc58adcd299c1f7ae37e834cbae3b027.zip |
Merge pull request #4098 from nextcloud/feature/caldav_search
add Nextcloud Search extension to CalDAV
Diffstat (limited to 'apps/dav')
18 files changed, 1761 insertions, 1 deletions
diff --git a/apps/dav/appinfo/database.xml b/apps/dav/appinfo/database.xml index 0efd2a46d61..b3a69de070c 100644 --- a/apps/dav/appinfo/database.xml +++ b/apps/dav/appinfo/database.xml @@ -671,6 +671,78 @@ CREATE TABLE calendarobjects ( </table> <table> + <name>*dbprefix*calendarobjects_props</name> + <declaration> + <field> + <name>id</name> + <type>integer</type> + <default>0</default> + <notnull>true</notnull> + <autoincrement>1</autoincrement> + <unsigned>true</unsigned> + <length>11</length> + </field> + <field> + <name>calendarid</name> + <type>integer</type> + <default></default> + <notnull>true</notnull> + <length>11</length> + </field> + <field> + <name>objectid</name> + <type>integer</type> + <default></default> + <notnull>true</notnull> + <unsigned>true</unsigned> + <length>11</length> + </field> + <field> + <name>name</name> + <type>text</type> + <default></default> + <notnull>false</notnull> + <length>64</length> + </field> + <field> + <name>parameter</name> + <type>text</type> + <default></default> + <notnull>false</notnull> + <length>64</length> + </field> + <field> + <name>value</name> + <type>text</type> + <default></default> + <notnull>false</notnull> + <length>255</length> + </field> + <index> + <name>calendarobject_index</name> + <field> + <name>objectid</name> + <sorting>ascending</sorting> + </field> + </index> + <index> + <name>calendarobject_name_index</name> + <field> + <name>name</name> + <sorting>ascending</sorting> + </field> + </index> + <index> + <name>calendarobject_value_index</name> + <field> + <name>value</name> + <sorting>ascending</sorting> + </field> + </index> + </declaration> + </table> + + <table> <name>*dbprefix*dav_shares</name> <declaration> <field> diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index 0902c487247..2d9f73b3f43 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -5,7 +5,7 @@ <description>WebDAV endpoint</description> <licence>AGPL</licence> <author>owncloud.org</author> - <version>1.2.0</version> + <version>1.3.0</version> <default_enable/> <types> <filesystem/> @@ -24,6 +24,7 @@ <post-migration> <step>OCA\DAV\Migration\FixBirthdayCalendarComponent</step> <step>OCA\DAV\Migration\CalDAVRemoveEmptyValue</step> + <step>OCA\DAV\Migration\BuildCalendarSearchIndex</step> </post-migration> </repair-steps> <commands> diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index a4fed4f1982..fcf73cb0f31 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1,11 +1,13 @@ <?php /** * @copyright Copyright (c) 2016, ownCloud, Inc. + * @copyright Copyright (c) 2017 Georg Ehrke * * @author Joas Schilling <coding@schilljs.com> * @author Stefan Weil <sw@weilnetz.de> * @author Thomas Citharel <tcit@tcit.fr> * @author Thomas Müller <thomas.mueller@tmit.eu> + * @author Georg Ehrke <oc.list@georgehrke.com> * * @license AGPL-3.0 * @@ -44,6 +46,7 @@ use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\PropPatch; use Sabre\HTTP\URLUtil; +use Sabre\VObject\Component\VCalendar; use Sabre\VObject\DateTimeParser; use Sabre\VObject\Reader; use Sabre\VObject\Recur\EventIterator; @@ -108,6 +111,17 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription '{http://calendarserver.org/ns/}subscribed-strip-attachments' => 'stripattachments', ]; + /** @var array properties to index */ + public static $indexProperties = ['CATEGORIES', 'COMMENT', 'DESCRIPTION', + 'LOCATION', 'RESOURCES', 'STATUS', 'SUMMARY', 'ATTENDEE', 'CONTACT', + 'ORGANIZER']; + + /** @var array parameters to index */ + public static $indexParameters = [ + 'ATTENDEE' => ['CN'], + 'ORGANIZER' => ['CN'], + ]; + /** * @var string[] Map of uid => display name */ @@ -134,6 +148,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** @var bool */ private $legacyEndpoint; + /** @var string */ + private $dbObjectPropertiesTable = 'calendarobjects_props'; + /** * CalDavBackend constructor. * @@ -746,6 +763,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt->execute([$calendarId]); $this->sharingBackend->deleteAllShares($calendarId); + + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->execute(); } /** @@ -940,6 +962,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ]) ->execute(); + $this->updateProperties($calendarId, $objectUri, $calendarData); + $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject', new GenericEvent( '\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject', [ @@ -990,6 +1014,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) ->execute(); + $this->updateProperties($calendarId, $objectUri, $calendarData); + $data = $this->getCalendarObject($calendarId, $objectUri); if (is_array($data)) { $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCalendarObject', new GenericEvent( @@ -1050,6 +1076,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ?'); $stmt->execute([$calendarId, $objectUri]); + $this->purgeProperties($calendarId, $data['id']); + $this->addChange($calendarId, $objectUri, 3); } @@ -1168,6 +1196,125 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * custom Nextcloud search extension for CalDAV + * + * @param string $principalUri + * @param array $filters + * @param integer|null $limit + * @param integer|null $offset + * @return array + */ + public function calendarSearch($principalUri, array $filters, $limit=null, $offset=null) { + $calendars = $this->getCalendarsForUser($principalUri); + $ownCalendars = []; + $sharedCalendars = []; + + $uriMapper = []; + + foreach($calendars as $calendar) { + if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) { + $ownCalendars[] = $calendar['id']; + } else { + $sharedCalendars[] = $calendar['id']; + } + $uriMapper[$calendar['id']] = $calendar['uri']; + } + if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) { + return []; + } + + $query = $this->db->getQueryBuilder(); + // Calendar id expressions + $calendarExpressions = []; + foreach($ownCalendars as $id) { + $calendarExpressions[] = $query->expr() + ->eq('c.calendarid', $query->createNamedParameter($id)); + } + foreach($sharedCalendars as $id) { + $calendarExpressions[] = $query->expr()->andX( + $query->expr()->eq('c.calendarid', + $query->createNamedParameter($id)), + $query->expr()->eq('c.classification', + $query->createNamedParameter(self::CLASSIFICATION_PUBLIC)) + ); + } + + if (count($calendarExpressions) === 1) { + $calExpr = $calendarExpressions[0]; + } else { + $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions); + } + + // Component expressions + $compExpressions = []; + foreach($filters['comps'] as $comp) { + $compExpressions[] = $query->expr() + ->eq('c.componenttype', $query->createNamedParameter($comp)); + } + + if (count($compExpressions) === 1) { + $compExpr = $compExpressions[0]; + } else { + $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions); + } + + if (!isset($filters['props'])) { + $filters['props'] = []; + } + if (!isset($filters['params'])) { + $filters['params'] = []; + } + + $propParamExpressions = []; + foreach($filters['props'] as $prop) { + $propParamExpressions[] = $query->expr()->andX( + $query->expr()->eq('i.name', $query->createNamedParameter($prop)), + $query->expr()->isNull('i.parameter') + ); + } + foreach($filters['params'] as $param) { + $propParamExpressions[] = $query->expr()->andX( + $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])), + $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter'])) + ); + } + + if (count($propParamExpressions) === 1) { + $propParamExpr = $propParamExpressions[0]; + } else { + $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions); + } + + $query->select(['c.calendarid', 'c.uri']) + ->from($this->dbObjectPropertiesTable, 'i') + ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id')) + ->where($calExpr) + ->andWhere($compExpr) + ->andWhere($propParamExpr) + ->andWhere($query->expr()->iLike('i.value', + $query->createNamedParameter('%'.$this->db->escapeLikeParameter($filters['search-term']).'%'))); + + if ($offset) { + $query->setFirstResult($offset); + } + if ($limit) { + $query->setMaxResults($limit); + } + + $stmt = $query->execute(); + + $result = []; + while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $path = $uriMapper[$row['calendarid']] . '/' . $row['uri']; + if (!in_array($path, $result)) { + $result[] = $path; + } + } + + return $result; + } + + /** * Searches through all of a users calendars and calendar objects to find * an object with a specific UID. * @@ -1820,6 +1967,130 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return $this->sharingBackend->applyShareAcl($resourceId, $acl); } + + + /** + * update properties table + * + * @param int $calendarId + * @param string $objectUri + * @param string $calendarData + */ + public function updateProperties($calendarId, $objectUri, $calendarData) { + $objectId = $this->getCalendarObjectId($calendarId, $objectUri); + + try { + $vCalendar = $this->readCalendarData($calendarData); + } catch (\Exception $ex) { + return; + } + + $this->purgeProperties($calendarId, $objectId); + + $query = $this->db->getQueryBuilder(); + $query->insert($this->dbObjectPropertiesTable) + ->values( + [ + 'calendarid' => $query->createNamedParameter($calendarId), + 'objectid' => $query->createNamedParameter($objectId), + 'name' => $query->createParameter('name'), + 'parameter' => $query->createParameter('parameter'), + 'value' => $query->createParameter('value'), + ] + ); + + $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO']; + foreach ($vCalendar->getComponents() as $component) { + if (!in_array($component->name, $indexComponents)) { + continue; + } + + foreach ($component->children() as $property) { + if (in_array($property->name, self::$indexProperties)) { + $value = $property->getValue(); + // is this a shitty db? + if ($this->db->supports4ByteText()) { + $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + } + $value = substr($value, 0, 254); + + $query->setParameter('name', $property->name); + $query->setParameter('parameter', null); + $query->setParameter('value', $value); + $query->execute(); + } + + if (in_array($property->name, array_keys(self::$indexParameters))) { + $parameters = $property->parameters(); + $indexedParametersForProperty = self::$indexParameters[$property->name]; + + foreach ($parameters as $key => $value) { + if (in_array($key, $indexedParametersForProperty)) { + // is this a shitty db? + if ($this->db->supports4ByteText()) { + $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + } + $value = substr($value, 0, 254); + + $query->setParameter('name', $property->name); + $query->setParameter('parameter', substr($key, 0, 254)); + $query->setParameter('value', substr($value, 0, 254)); + $query->execute(); + } + } + } + } + } + } + + /** + * read VCalendar data into a VCalendar object + * + * @param string $objectData + * @return VCalendar + */ + protected function readCalendarData($objectData) { + return Reader::read($objectData); + } + + /** + * delete all properties from a given calendar object + * + * @param int $calendarId + * @param int $objectId + */ + protected function purgeProperties($calendarId, $objectId) { + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('objectid', $query->createNamedParameter($objectId))) + ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))); + $query->execute(); + } + + /** + * get ID from a given calendar object + * + * @param int $calendarId + * @param string $uri + * @return int + */ + protected function getCalendarObjectId($calendarId, $uri) { + $query = $this->db->getQueryBuilder(); + $query->select('id')->from('calendarobjects') + ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) + ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))); + + $result = $query->execute(); + $objectIds = $result->fetch(); + $result->closeCursor(); + + if (!isset($objectIds['id'])) { + throw new \InvalidArgumentException('Calendarobject does not exists: ' . $uri); + } + + return (int)$objectIds['id']; + } + private function convertPrincipal($principalUri, $toV2) { if ($this->principalBackend->getPrincipalPrefix() === 'principals') { list(, $name) = URLUtil::splitPath($principalUri); diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index 7320754e6df..2aa2c9caa36 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -111,4 +111,14 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { throw new NotFound('Node with name \'' . $name . '\' could not be found'); } + + /** + * @param array $filters + * @param integer|null $limit + * @param integer|null $offset + */ + function calendarSearch(array $filters, $limit=null, $offset=null) { + $principalUri = $this->principalInfo['uri']; + return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset); + } } diff --git a/apps/dav/lib/CalDAV/Search/SearchPlugin.php b/apps/dav/lib/CalDAV/Search/SearchPlugin.php new file mode 100644 index 00000000000..d658a50437d --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/SearchPlugin.php @@ -0,0 +1,159 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search; + +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use OCA\DAV\CalDAV\CalendarHome; + +class SearchPlugin extends ServerPlugin { + const NS_Nextcloud = 'http://nextcloud.com/ns'; + + /** + * Reference to SabreDAV server object. + * + * @var \Sabre\DAV\Server + */ + protected $server; + + /** + * This method should return a list of server-features. + * + * This is for example 'versioning' and is added to the DAV: header + * in an OPTIONS response. + * + * @return string[] + */ + public function getFeatures() { + // May have to be changed to be detected + return ['nc-calendar-search']; + } + + /** + * Returns a plugin name. + * + * Using this name other plugins will be able to access other plugins + * using Sabre\DAV\Server::getPlugin + * + * @return string + */ + public function getPluginName() { + return 'nc-calendar-search'; + } + + /** + * This initializes the plugin. + * + * This function is called by Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param Server $server + */ + public function initialize(Server $server) { + $this->server = $server; + + $server->on('report', [$this, 'report']); + + $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] = + 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport'; + } + + /** + * This functions handles REPORT requests specific to CalDAV + * + * @param string $reportName + * @param mixed $report + * @param mixed $path + * @return bool + */ + public function report($reportName, $report, $path) { + switch ($reportName) { + case '{' . self::NS_Nextcloud . '}calendar-search': + $this->server->transactionType = 'report-nc-calendar-search'; + $this->calendarSearch($report); + return false; + } + } + + /** + * Returns a list of reports this plugin supports. + * + * This will be used in the {DAV:}supported-report-set property. + * Note that you still need to subscribe to the 'report' event to actually + * implement them + * + * @param string $uri + * @return array + */ + public function getSupportedReportSet($uri) { + $node = $this->server->tree->getNodeForPath($uri); + + $reports = []; + if ($node instanceof CalendarHome) { + $reports[] = '{' . self::NS_Nextcloud . '}calendar-search'; + } + + return $reports; + } + + /** + * This function handles the calendar-query REPORT + * + * This report is used by clients to request calendar objects based on + * complex conditions. + * + * @param Xml\Request\CalendarSearchReport $report + * @return void + */ + private function calendarSearch($report) { + $node = $this->server->tree->getNodeForPath($this->server->getRequestUri()); + $depth = $this->server->getHTTPDepth(2); + + // The default result is an empty array + $result = []; + + // If we're dealing with the calendar home, the calendar home itself is + // responsible for the calendar-query + if ($node instanceof CalendarHome && $depth == 2) { + + $nodePaths = $node->calendarSearch($report->filters, $report->limit, $report->offset); + + foreach ($nodePaths as $path) { + list($properties) = $this->server->getPropertiesForPath( + $this->server->getRequestUri() . '/' . $path, + $report->properties); + $result[] = $properties; + } + } + + $prefer = $this->server->getHTTPPrefer(); + + $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setHeader('Content-Type', + 'application/xml; charset=utf-8'); + $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); + $this->server->httpResponse->setBody( + $this->server->generateMultiStatus($result, + $prefer['return'] === 'minimal')); + } +}
\ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php new file mode 100644 index 00000000000..a2a3c910f05 --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php @@ -0,0 +1,47 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Filter; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +class CompFilter implements XmlDeserializable { + + /** + * @param Reader $reader + * @throws BadRequest + * @return string + */ + static function xmlDeserialize(Reader $reader) { + $att = $reader->parseAttributes(); + $componentName = $att['name']; + + $reader->parseInnerTree(); + + if (!is_string($componentName)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}comp-filter requires a valid name attribute'); + } + + return $componentName; + } +} diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php new file mode 100644 index 00000000000..f5de78b539c --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php @@ -0,0 +1,43 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Filter; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +class LimitFilter implements XmlDeserializable { + + /** + * @param Reader $reader + * @throws BadRequest + * @return int + */ + static function xmlDeserialize(Reader $reader) { + $value = $reader->parseInnerTree(); + if (!is_int($value) && !is_string($value)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}limit has illegal value'); + } + + return intval($value); + } +}
\ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php new file mode 100644 index 00000000000..7257e1e72f9 --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php @@ -0,0 +1,43 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Filter; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +class OffsetFilter implements XmlDeserializable { + + /** + * @param Reader $reader + * @throws BadRequest + * @return int + */ + static function xmlDeserialize(Reader $reader) { + $value = $reader->parseInnerTree(); + if (!is_int($value) && !is_string($value)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}offset has illegal value'); + } + + return intval($value); + } +}
\ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php new file mode 100644 index 00000000000..1c443763daf --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php @@ -0,0 +1,55 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Filter; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +class ParamFilter implements XmlDeserializable { + + /** + * @param Reader $reader + * @throws BadRequest + * @return string + */ + static function xmlDeserialize(Reader $reader) { + $att = $reader->parseAttributes(); + $property = $att['property']; + $parameter = $att['name']; + + $reader->parseInnerTree(); + + if (!is_string($property)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}param-filter requires a valid property attribute'); + + } + if (!is_string($parameter)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}param-filter requires a valid parameter attribute'); + } + + return [ + 'property' => $property, + 'parameter' => $parameter, + ]; + } +} diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php new file mode 100644 index 00000000000..06b41d91c6e --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php @@ -0,0 +1,47 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Filter; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +class PropFilter implements XmlDeserializable { + + /** + * @param Reader $reader + * @throws BadRequest + * @return string + */ + static function xmlDeserialize(Reader $reader) { + $att = $reader->parseAttributes(); + $componentName = $att['name']; + + $reader->parseInnerTree(); + + if (!is_string($componentName)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}prop-filter requires a valid name attribute'); + } + + return $componentName; + } +} diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php new file mode 100644 index 00000000000..8779e2b3940 --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php @@ -0,0 +1,43 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Filter; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +class SearchTermFilter implements XmlDeserializable { + + /** + * @param Reader $reader + * @throws BadRequest + * @return string + */ + static function xmlDeserialize(Reader $reader) { + $value = $reader->parseInnerTree(); + if (!is_string($value)) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}search-term has illegal value'); + } + + return $value; + } +}
\ No newline at end of file diff --git a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php new file mode 100644 index 00000000000..4d22f310c24 --- /dev/null +++ b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php @@ -0,0 +1,167 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Request; + +use Sabre\DAV\Exception\BadRequest; +use Sabre\Xml\Reader; +use Sabre\Xml\XmlDeserializable; +use OCA\DAV\CalDAV\Search\SearchPlugin; + +/** + * CalendarSearchReport request parser. + * + * This class parses the {urn:ietf:params:xml:ns:caldav}calendar-query + * REPORT, as defined in: + * + * https:// link to standard + */ +class CalendarSearchReport implements XmlDeserializable { + + /** + * An array with requested properties. + * + * @var array + */ + public $properties; + + /** + * List of property/component filters. + * + * @var array + */ + public $filters; + + /** + * @var int + */ + public $limit; + + /** + * @var int + */ + public $offset; + + /** + * The deserialize method is called during xml parsing. + * + * This method is called statically, this is because in theory this method + * may be used as a type of constructor, or factory method. + * + * Often you want to return an instance of the current class, but you are + * free to return other data as well. + * + * You are responsible for advancing the reader to the next element. Not + * doing anything will result in a never-ending loop. + * + * If you just want to skip parsing for this element altogether, you can + * just call $reader->next(); + * + * $reader->parseInnerTree() will parse the entire sub-tree, and advance to + * the next element. + * + * @param Reader $reader + * @return mixed + */ + static function xmlDeserialize(Reader $reader) { + $elems = $reader->parseInnerTree([ + '{http://nextcloud.com/ns}comp-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter', + '{http://nextcloud.com/ns}prop-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter', + '{http://nextcloud.com/ns}param-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\ParamFilter', + '{http://nextcloud.com/ns}search-term' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter', + '{http://nextcloud.com/ns}limit' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter', + '{http://nextcloud.com/ns}offset' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\OffsetFilter', + '{DAV:}prop' => 'Sabre\\Xml\\Element\\KeyValue', + ]); + + $newProps = [ + 'filters' => [], + 'properties' => [], + 'limit' => null, + 'offset' => null + ]; + + if (!is_array($elems)) { + $elems = []; + } + + foreach ($elems as $elem) { + switch ($elem['name']) { + case '{DAV:}prop': + $newProps['properties'] = array_keys($elem['value']); + break; + case '{' . SearchPlugin::NS_Nextcloud . '}filter': + foreach ($elem['value'] as $subElem) { + if ($subElem['name'] === '{' . SearchPlugin::NS_Nextcloud . '}comp-filter') { + if (!isset($newProps['filters']['comps']) || !is_array($newProps['filters']['comps'])) { + $newProps['filters']['comps'] = []; + } + $newProps['filters']['comps'][] = $subElem['value']; + } elseif ($subElem['name'] === '{' . SearchPlugin::NS_Nextcloud . '}prop-filter') { + if (!isset($newProps['filters']['props']) || !is_array($newProps['filters']['props'])) { + $newProps['filters']['props'] = []; + } + $newProps['filters']['props'][] = $subElem['value']; + } elseif ($subElem['name'] === '{' . SearchPlugin::NS_Nextcloud . '}param-filter') { + if (!isset($newProps['filters']['params']) || !is_array($newProps['filters']['params'])) { + $newProps['filters']['params'] = []; + } + $newProps['filters']['params'][] = $subElem['value']; + } elseif ($subElem['name'] === '{' . SearchPlugin::NS_Nextcloud . '}search-term') { + $newProps['filters']['search-term'] = $subElem['value']; + } + } + break; + case '{' . SearchPlugin::NS_Nextcloud . '}limit': + $newProps['limit'] = $elem['value']; + break; + case '{' . SearchPlugin::NS_Nextcloud . '}offset': + $newProps['offset'] = $elem['value']; + break; + + } + } + + if (empty($newProps['filters'])) { + throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}filter element is required for this request'); + } + + $propsOrParamsDefined = (!empty($newProps['filters']['props']) || !empty($newProps['filters']['params'])); + $noCompsDefined = empty($newProps['filters']['comps']); + if ($propsOrParamsDefined && $noCompsDefined) { + throw new BadRequest('{' . SearchPlugin::NS_Nextcloud . '}prop-filter or {' . SearchPlugin::NS_Nextcloud . '}param-filter given without any {' . SearchPlugin::NS_Nextcloud . '}comp-filter'); + } + + if (!isset($newProps['filters']['search-term'])) { + throw new BadRequest('{' . SearchPlugin::NS_Nextcloud . '}search-term is required for this request'); + } + + if (empty($newProps['filters']['props']) && empty($newProps['filters']['params'])) { + throw new BadRequest('At least one{' . SearchPlugin::NS_Nextcloud . '}prop-filter or {' . SearchPlugin::NS_Nextcloud . '}param-filter is required for this request'); + } + + + $obj = new self(); + foreach ($newProps as $key => $value) { + $obj->$key = $value; + } + return $obj; + } +} diff --git a/apps/dav/lib/Migration/BuildCalendarSearchIndex.php b/apps/dav/lib/Migration/BuildCalendarSearchIndex.php new file mode 100644 index 00000000000..da4b4f4fe84 --- /dev/null +++ b/apps/dav/lib/Migration/BuildCalendarSearchIndex.php @@ -0,0 +1,86 @@ +<?php +/** + * @copyright 2017 Georg Ehrke <oc.list@georgehrke.com> + * + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCA\DAV\Migration; + +use OCP\BackgroundJob\IJobList; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class BuildCalendarSearchIndex implements IRepairStep { + + /** @var IDBConnection */ + private $db; + + /** @var IJobList */ + private $jobList; + + /** @var IConfig */ + private $config; + + /** + * @param IDBConnection $db + * @param IJobList $jobList + * @param IConfig $config + */ + public function __construct(IDBConnection $db, + IJobList $jobList, + IConfig $config) { + $this->db = $db; + $this->jobList = $jobList; + $this->config = $config; + } + + /** + * @return string + */ + public function getName() { + return 'Registering building of calendar search index as background job'; + } + + /** + * @param IOutput $output + */ + public function run(IOutput $output) { + // only run once + if ($this->config->getAppValue('dav', 'buildCalendarSearchIndex') === 'yes') { + $output->info('Repair step already executed'); + return; + } + + $query = $this->db->getQueryBuilder(); + $query->select($query->createFunction('MAX(id)')) + ->from('calendarobjects'); + $maxId = (int)$query->execute()->fetchColumn(); + + $output->info('Add background job'); + $this->jobList->add(BuildCalendarSearchIndexBackgroundJob::class, [ + 'offset' => 0, + 'stopAt' => $maxId + ]); + + // if all were done, no need to redo the repair during next upgrade + $this->config->setAppValue('dav', 'buildCalendarSearchIndex', 'yes'); + } +}
\ No newline at end of file diff --git a/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php b/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php new file mode 100644 index 00000000000..a4fa2c63e02 --- /dev/null +++ b/apps/dav/lib/Migration/BuildCalendarSearchIndexBackgroundJob.php @@ -0,0 +1,120 @@ +<?php +/** + * @copyright 2017 Georg Ehrke <oc.list@georgehrke.com> + * + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ +namespace OCA\DAV\Migration; + +use OC\BackgroundJob\QueuedJob; +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJobList; +use OCP\IDBConnection; +use OCP\ILogger; + +class BuildCalendarSearchIndexBackgroundJob extends QueuedJob { + + /** @var IDBConnection */ + private $db; + + /** @var CalDavBackend */ + private $calDavBackend; + + /** @var ILogger */ + private $logger; + + /** @var IJobList */ + private $jobList; + + /** @var ITimeFactory */ + private $timeFactory; + + /** + * @param IDBConnection $db + * @param CalDavBackend $calDavBackend + * @param ILogger $logger + * @param IJobList $jobList + * @param ITimeFactory $timeFactory + */ + public function __construct(IDBConnection $db, + CalDavBackend $calDavBackend, + ILogger $logger, + IJobList $jobList, + ITimeFactory $timeFactory) { + $this->db = $db; + $this->calDavBackend = $calDavBackend; + $this->logger = $logger; + $this->jobList = $jobList; + $this->timeFactory = $timeFactory; + } + + public function run($arguments) { + $offset = $arguments['offset']; + $stopAt = $arguments['stopAt']; + + $this->logger->info('Building calendar index (' . $offset .'/' . $stopAt . ')'); + + $offset = $this->buildIndex($offset, $stopAt); + + if ($offset >= $stopAt) { + $this->logger->info('Building calendar index done'); + } else { + $this->jobList->add(self::class, [ + 'offset' => $offset, + 'stopAt' => $stopAt + ]); + $this->logger->info('New building calendar index job scheduled with offset ' . $offset); + } + } + + /** + * @param int $offset + * @param int $stopAt + * @return int + */ + private function buildIndex($offset, $stopAt) { + $startTime = $this->timeFactory->getTime(); + + $query = $this->db->getQueryBuilder(); + $query->select(['id', 'calendarid', 'uri', 'calendardata']) + ->from('calendarobjects') + ->where($query->expr()->lte('id', $query->createNamedParameter($stopAt))) + ->andWhere($query->expr()->gt('id', $query->createNamedParameter($offset))) + ->orderBy('id', 'ASC'); + + $stmt = $query->execute(); + while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $offset = $row['id']; + + $calendarData = $row['calendardata']; + if (is_resource($calendarData)) { + $calendarData = stream_get_contents($calendarData); + } + + $this->calDavBackend->updateProperties($row['calendarid'], $row['uri'], $calendarData); + + if (($this->timeFactory->getTime() - $startTime) > 15) { + return $offset; + } + } + + return $stopAt; + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index e0517477488..5b0715b0dad 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -216,6 +216,7 @@ class Server { \OC::$server->getCommentsManager(), $userSession )); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Search\SearchPlugin()); if ($view !== null) { $this->server->addPlugin(new FilesReportPlugin( $this->server->tree, diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index 5adda30c19d..dc531b5a64a 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -1,10 +1,12 @@ <?php /** * @copyright Copyright (c) 2016, ownCloud, Inc. + * @copyright Copyright (c) 2017 Georg Ehrke * * @author Joas Schilling <coding@schilljs.com> * @author Thomas Citharel <tcit@tcit.fr> * @author Thomas Müller <thomas.mueller@tmit.eu> + * @author Georg Ehrke <oc.list@georgehrke.com> * * @license AGPL-3.0 * @@ -489,4 +491,134 @@ EOD; 'unknown class -> private' => [CalDavBackend::CLASSIFICATION_PRIVATE, 'classification', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//dmfs.org//mimedir.icalendar//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nX-LIC-LOCATION:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nTZNAME:CEST\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTART;TZID=Europe/Berlin:20160419T130000\r\nSUMMARY:Test\r\nCLASS:VERTRAULICH\r\nTRANSP:OPAQUE\r\nSTATUS:CONFIRMED\r\nDTEND;TZID=Europe/Berlin:20160419T140000\r\nLAST-MODIFIED:20160419T074202Z\r\nDTSTAMP:20160419T074202Z\r\nCREATED:20160419T074202Z\r\nUID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310\r\nEND:VEVENT\r\nEND:VCALENDAR"], ]; } + + public function testCalendarSearch() { + $calendarId = $this->createTestCalendar(); + + $uri = static::getUniqueID('calobj'); + $calData = <<<EOD +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:ownCloud Calendar +BEGIN:VEVENT +CREATED;VALUE=DATE-TIME:20130910T125139Z +UID:47d15e3ec8 +LAST-MODIFIED;VALUE=DATE-TIME:20130910T125139Z +DTSTAMP;VALUE=DATE-TIME:20130910T125139Z +SUMMARY:Test Event +DTSTART;VALUE=DATE-TIME:20130912T130000Z +DTEND;VALUE=DATE-TIME:20130912T140000Z +CLASS:PUBLIC +END:VEVENT +END:VCALENDAR +EOD; + + $this->backend->createCalendarObject($calendarId, $uri, $calData); + + $search1 = $this->backend->calendarSearch(self::UNIT_TEST_USER, [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'props' => [ + 'SUMMARY', + 'LOCATION' + ], + 'search-term' => 'Test', + ]); + $this->assertEquals(count($search1), 1); + + + // update the card + $calData = <<<'EOD' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:ownCloud Calendar +BEGIN:VEVENT +CREATED;VALUE=DATE-TIME:20130910T125139Z +UID:47d15e3ec8 +LAST-MODIFIED;VALUE=DATE-TIME:20130910T125139Z +DTSTAMP;VALUE=DATE-TIME:20130910T125139Z +SUMMARY:123 Event +DTSTART;VALUE=DATE-TIME:20130912T130000Z +DTEND;VALUE=DATE-TIME:20130912T140000Z +ATTENDEE;CN=test:mailto:foo@bar.com +END:VEVENT +END:VCALENDAR +EOD; + $this->backend->updateCalendarObject($calendarId, $uri, $calData); + + $search2 = $this->backend->calendarSearch(self::UNIT_TEST_USER, [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'props' => [ + 'SUMMARY', + 'LOCATION' + ], + 'search-term' => 'Test', + ]); + $this->assertEquals(count($search2), 0); + + $search3 = $this->backend->calendarSearch(self::UNIT_TEST_USER, [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'props' => [ + 'SUMMARY', + 'LOCATION' + ], + 'params' => [ + [ + 'property' => 'ATTENDEE', + 'parameter' => 'CN' + ] + ], + 'search-term' => 'Test', + ]); + $this->assertEquals(count($search3), 1); + + // t matches both summary and attendee's CN, but we want unique results + $search4 = $this->backend->calendarSearch(self::UNIT_TEST_USER, [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'props' => [ + 'SUMMARY', + 'LOCATION' + ], + 'params' => [ + [ + 'property' => 'ATTENDEE', + 'parameter' => 'CN' + ] + ], + 'search-term' => 't', + ]); + $this->assertEquals(count($search4), 1); + + $this->backend->deleteCalendarObject($calendarId, $uri); + + $search5 = $this->backend->calendarSearch(self::UNIT_TEST_USER, [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'props' => [ + 'SUMMARY', + 'LOCATION' + ], + 'params' => [ + [ + 'property' => 'ATTENDEE', + 'parameter' => 'CN' + ] + ], + 'search-term' => 't', + ]); + $this->assertEquals(count($search5), 0); + } } diff --git a/apps/dav/tests/unit/CalDAV/Search/Request/CalendarSearchReportTest.php b/apps/dav/tests/unit/CalDAV/Search/Request/CalendarSearchReportTest.php new file mode 100644 index 00000000000..20bac8aa9f5 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Search/Request/CalendarSearchReportTest.php @@ -0,0 +1,339 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search\Xml\Request; + +use OCA\DAV\CalDAV\Search\Xml\Request\CalendarSearchReport; +use Sabre\Xml\Reader; +use Test\TestCase; + +class CalendarSearchReportTest extends TestCase { + + private $elementMap = [ + '{http://nextcloud.com/ns}calendar-search' => + 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport', + ]; + + public function testFoo() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:comp-filter name="VEVENT" /> + <nc:comp-filter name="VTODO" /> + <nc:prop-filter name="SUMMARY" /> + <nc:prop-filter name="LOCATION" /> + <nc:prop-filter name="ATTENDEE" /> + <nc:param-filter property="ATTENDEE" name="CN" /> + <nc:search-term>foo</nc:search-term> + </nc:filter> + <nc:limit>10</nc:limit> + <nc:offset>5</nc:offset> +</nc:calendar-search> +XML; + + $result = $this->parse($xml); + + $calendarSearchReport = new CalendarSearchReport(); + $calendarSearchReport->properties = [ + '{DAV:}getetag', + '{urn:ietf:params:xml:ns:caldav}calendar-data', + ]; + $calendarSearchReport->filters = [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'props' => [ + 'SUMMARY', + 'LOCATION', + 'ATTENDEE' + ], + 'params' => [ + [ + 'property' => 'ATTENDEE', + 'parameter' => 'CN' + ] + ], + 'search-term' => 'foo' + ]; + $calendarSearchReport->limit = 10; + $calendarSearchReport->offset = 5; + + $this->assertEquals( + $calendarSearchReport, + $result['value'] + ); + } + + public function testNoLimitOffset() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:comp-filter name="VEVENT" /> + <nc:prop-filter name="SUMMARY" /> + <nc:search-term>foo</nc:search-term> + </nc:filter> +</nc:calendar-search> +XML; + + $result = $this->parse($xml); + + $calendarSearchReport = new CalendarSearchReport(); + $calendarSearchReport->properties = [ + '{DAV:}getetag', + '{urn:ietf:params:xml:ns:caldav}calendar-data', + ]; + $calendarSearchReport->filters = [ + 'comps' => [ + 'VEVENT', + ], + 'props' => [ + 'SUMMARY', + ], + 'search-term' => 'foo' + ]; + $calendarSearchReport->limit = null; + $calendarSearchReport->offset = null; + + $this->assertEquals( + $calendarSearchReport, + $result['value'] + ); + } + + /** + * @expectedException \Sabre\DAV\Exception\BadRequest + * @expectedExceptionMessage {http://nextcloud.com/ns}prop-filter or {http://nextcloud.com/ns}param-filter given without any {http://nextcloud.com/ns}comp-filter + */ + public function testRequiresCompFilter() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:prop-filter name="SUMMARY" /> + <nc:prop-filter name="LOCATION" /> + <nc:prop-filter name="ATTENDEE" /> + <nc:param-filter property="ATTENDEE" name="CN" /> + <nc:search-term>foo</nc:search-term> + </nc:filter> + <nc:limit>10</nc:limit> + <nc:offset>5</nc:offset> +</nc:calendar-search> +XML; + + $this->parse($xml); + } + + /** + * @expectedException \Sabre\DAV\Exception\BadRequest + * @expectedExceptionMessage The {http://nextcloud.com/ns}filter element is required for this request + */ + public function testRequiresFilter() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> +</nc:calendar-search> +XML; + + $this->parse($xml); + } + + /** + * @expectedException \Sabre\DAV\Exception\BadRequest + * @expectedExceptionMessage {http://nextcloud.com/ns}search-term is required for this request + */ + public function testNoSearchTerm() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:comp-filter name="VEVENT" /> + <nc:comp-filter name="VTODO" /> + <nc:prop-filter name="SUMMARY" /> + <nc:prop-filter name="LOCATION" /> + <nc:prop-filter name="ATTENDEE" /> + <nc:param-filter property="ATTENDEE" name="CN" /> + </nc:filter> + <nc:limit>10</nc:limit> + <nc:offset>5</nc:offset> +</nc:calendar-search> +XML; + + $result = $this->parse($xml); + } + + /** + * @expectedException \Sabre\DAV\Exception\BadRequest + * @expectedExceptionMessage At least one{http://nextcloud.com/ns}prop-filter or {http://nextcloud.com/ns}param-filter is required for this request + */ + public function testCompOnly() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:comp-filter name="VEVENT" /> + <nc:comp-filter name="VTODO" /> + <nc:search-term>foo</nc:search-term> + </nc:filter> +</nc:calendar-search> +XML; + + $result = $this->parse($xml); + + $calendarSearchReport = new CalendarSearchReport(); + $calendarSearchReport->properties = [ + '{DAV:}getetag', + '{urn:ietf:params:xml:ns:caldav}calendar-data', + ]; + $calendarSearchReport->filters = [ + 'comps' => [ + 'VEVENT', + 'VTODO' + ], + 'search-term' => 'foo' + ]; + $calendarSearchReport->limit = null; + $calendarSearchReport->offset = null; + + $this->assertEquals( + $calendarSearchReport, + $result['value'] + ); + } + + public function testPropOnly() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:comp-filter name="VEVENT" /> + <nc:prop-filter name="SUMMARY" /> + <nc:search-term>foo</nc:search-term> + </nc:filter> +</nc:calendar-search> +XML; + + $result = $this->parse($xml); + + $calendarSearchReport = new CalendarSearchReport(); + $calendarSearchReport->properties = [ + '{DAV:}getetag', + '{urn:ietf:params:xml:ns:caldav}calendar-data', + ]; + $calendarSearchReport->filters = [ + 'comps' => [ + 'VEVENT', + ], + 'props' => [ + 'SUMMARY', + ], + 'search-term' => 'foo' + ]; + $calendarSearchReport->limit = null; + $calendarSearchReport->offset = null; + + $this->assertEquals( + $calendarSearchReport, + $result['value'] + ); + } + + public function testParamOnly() { + $xml = <<<XML +<?xml version="1.0" encoding="UTF-8"?> +<nc:calendar-search xmlns:nc="http://nextcloud.com/ns" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:"> + <d:prop> + <d:getetag /> + <c:calendar-data /> + </d:prop> + <nc:filter> + <nc:comp-filter name="VEVENT" /> + <nc:param-filter property="ATTENDEE" name="CN" /> + <nc:search-term>foo</nc:search-term> + </nc:filter> +</nc:calendar-search> +XML; + + $result = $this->parse($xml); + + $calendarSearchReport = new CalendarSearchReport(); + $calendarSearchReport->properties = [ + '{DAV:}getetag', + '{urn:ietf:params:xml:ns:caldav}calendar-data', + ]; + $calendarSearchReport->filters = [ + 'comps' => [ + 'VEVENT', + ], + 'params' => [ + [ + 'property' => 'ATTENDEE', + 'parameter' => 'CN' + ] + ], + 'search-term' => 'foo' + ]; + $calendarSearchReport->limit = null; + $calendarSearchReport->offset = null; + + $this->assertEquals( + $calendarSearchReport, + $result['value'] + ); + } + + private function parse($xml, array $elementMap = []) { + $reader = new Reader(); + $reader->elementMap = array_merge($this->elementMap, $elementMap); + $reader->xml($xml); + return $reader->parse(); + } +} diff --git a/apps/dav/tests/unit/CalDAV/Search/SearchPluginTest.php b/apps/dav/tests/unit/CalDAV/Search/SearchPluginTest.php new file mode 100644 index 00000000000..fc647bb5daf --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Search/SearchPluginTest.php @@ -0,0 +1,124 @@ +<?php +/** + * @author Georg Ehrke <oc.list@georgehrke.com> + * + * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> + * @license GNU AGPL version 3 or any later version + * + * 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\CalDAV\Search; + +use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\Search\SearchPlugin; +use OCA\DAV\CalDAV\Search\Xml\Request\CalendarSearchReport; +use Test\TestCase; + +class SearchPluginTest extends TestCase { + + protected $server; + + /** @var \OCA\DAV\CalDAV\Search\SearchPlugin $plugin */ + protected $plugin; + + public function setUp() { + parent::setUp(); + + $this->server = $this->createMock(\Sabre\DAV\Server::class); + $this->server->tree = $this->createMock(\Sabre\DAV\Tree::class); + $this->server->httpResponse = $this->createMock(\Sabre\HTTP\Response::class); + + $this->plugin = new SearchPlugin(); + $this->plugin->initialize($this->server); + } + + public function testGetFeatures() { + $this->assertEquals(['nc-calendar-search'], $this->plugin->getFeatures()); + } + + public function testGetName() { + $this->assertEquals('nc-calendar-search', $this->plugin->getPluginName()); + } + + public function testInitialize() { + $server = $this->createMock(\Sabre\DAV\Server::class); + + $plugin = new SearchPlugin(); + + $server->expects($this->at(0)) + ->method('on') + ->with('report', [$plugin, 'report']); + + $plugin->initialize($server); + + $this->assertEquals( + $server->xml->elementMap['{http://nextcloud.com/ns}calendar-search'], + 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' + ); + } + + public function testReportUnknown() { + $result = $this->plugin->report('{urn:ietf:params:xml:ns:caldav}calendar-query', 'REPORT', null); + $this->assertEquals($result, null); + $this->assertNotEquals($this->server->transactionType, 'report-nc-calendar-search'); + } + + public function testReport() { + $report = $this->createMock(CalendarSearchReport::class); + $report->filters = []; + $calendarHome = $this->createMock(CalendarHome::class); + $this->server->expects($this->at(0)) + ->method('getRequestUri') + ->with() + ->will($this->returnValue('/re/quest/u/r/i')); + $this->server->tree->expects($this->at(0)) + ->method('getNodeForPath') + ->with('/re/quest/u/r/i') + ->will($this->returnValue($calendarHome)); + $this->server->expects($this->at(1)) + ->method('getHTTPDepth') + ->with(2) + ->will($this->returnValue(2)); + $calendarHome->expects($this->at(0)) + ->method('calendarSearch') + ->will($this->returnValue([])); + + $this->plugin->report('{http://nextcloud.com/ns}calendar-search', $report, ''); + } + + public function testSupportedReportSetNoCalendarHome() { + $this->server->tree->expects($this->once()) + ->method('getNodeForPath') + ->with('/foo/bar') + ->will($this->returnValue(null)); + + $reports = $this->plugin->getSupportedReportSet('/foo/bar'); + $this->assertEquals([], $reports); + } + + public function testSupportedReportSet() { + $calendarHome = $this->createMock(CalendarHome::class); + + $this->server->tree->expects($this->once()) + ->method('getNodeForPath') + ->with('/bar/foo') + ->will($this->returnValue($calendarHome)); + + $reports = $this->plugin->getSupportedReportSet('/bar/foo'); + $this->assertEquals([ + '{http://nextcloud.com/ns}calendar-search' + ], $reports); + } +} |