1<?php
2
3namespace Sabre\CalDAV\Backend;
4
5use Sabre\CalDAV;
6use Sabre\DAV;
7use Sabre\DAV\Exception\Forbidden;
8use Sabre\DAV\Xml\Element\Sharee;
9use Sabre\VObject;
10
11/**
12 * PDO CalDAV backend
13 *
14 * This backend is used to store calendar-data in a PDO database, such as
15 * sqlite or MySQL
16 *
17 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
18 * @author Evert Pot (http://evertpot.com/)
19 * @license http://sabre.io/license/ Modified BSD License
20 */
21class PDO extends AbstractBackend
22    implements
23        SyncSupport,
24        SubscriptionSupport,
25        SchedulingSupport,
26        SharingSupport {
27
28    /**
29     * We need to specify a max date, because we need to stop *somewhere*
30     *
31     * On 32 bit system the maximum for a signed integer is 2147483647, so
32     * MAX_DATE cannot be higher than date('Y-m-d', 2147483647) which results
33     * in 2038-01-19 to avoid problems when the date is converted
34     * to a unix timestamp.
35     */
36    const MAX_DATE = '2038-01-01';
37
38    /**
39     * pdo
40     *
41     * @var \PDO
42     */
43    protected $pdo;
44
45    /**
46     * The table name that will be used for calendars
47     *
48     * @var string
49     */
50    public $calendarTableName = 'calendars';
51
52    /**
53     * The table name that will be used for calendars instances.
54     *
55     * A single calendar can have multiple instances, if the calendar is
56     * shared.
57     *
58     * @var string
59     */
60    public $calendarInstancesTableName = 'calendarinstances';
61
62    /**
63     * The table name that will be used for calendar objects
64     *
65     * @var string
66     */
67    public $calendarObjectTableName = 'calendarobjects';
68
69    /**
70     * The table name that will be used for tracking changes in calendars.
71     *
72     * @var string
73     */
74    public $calendarChangesTableName = 'calendarchanges';
75
76    /**
77     * The table name that will be used inbox items.
78     *
79     * @var string
80     */
81    public $schedulingObjectTableName = 'schedulingobjects';
82
83    /**
84     * The table name that will be used for calendar subscriptions.
85     *
86     * @var string
87     */
88    public $calendarSubscriptionsTableName = 'calendarsubscriptions';
89
90    /**
91     * List of CalDAV properties, and how they map to database fieldnames
92     * Add your own properties by simply adding on to this array.
93     *
94     * Note that only string-based properties are supported here.
95     *
96     * @var array
97     */
98    public $propertyMap = [
99        '{DAV:}displayname'                                   => 'displayname',
100        '{urn:ietf:params:xml:ns:caldav}calendar-description' => 'description',
101        '{urn:ietf:params:xml:ns:caldav}calendar-timezone'    => 'timezone',
102        '{http://apple.com/ns/ical/}calendar-order'           => 'calendarorder',
103        '{http://apple.com/ns/ical/}calendar-color'           => 'calendarcolor',
104    ];
105
106    /**
107     * List of subscription properties, and how they map to database fieldnames.
108     *
109     * @var array
110     */
111    public $subscriptionPropertyMap = [
112        '{DAV:}displayname'                                           => 'displayname',
113        '{http://apple.com/ns/ical/}refreshrate'                      => 'refreshrate',
114        '{http://apple.com/ns/ical/}calendar-order'                   => 'calendarorder',
115        '{http://apple.com/ns/ical/}calendar-color'                   => 'calendarcolor',
116        '{http://calendarserver.org/ns/}subscribed-strip-todos'       => 'striptodos',
117        '{http://calendarserver.org/ns/}subscribed-strip-alarms'      => 'stripalarms',
118        '{http://calendarserver.org/ns/}subscribed-strip-attachments' => 'stripattachments',
119    ];
120
121    /**
122     * Creates the backend
123     *
124     * @param \PDO $pdo
125     */
126    function __construct(\PDO $pdo) {
127
128        $this->pdo = $pdo;
129
130    }
131
132    /**
133     * Returns a list of calendars for a principal.
134     *
135     * Every project is an array with the following keys:
136     *  * id, a unique id that will be used by other functions to modify the
137     *    calendar. This can be the same as the uri or a database key.
138     *  * uri. This is just the 'base uri' or 'filename' of the calendar.
139     *  * principaluri. The owner of the calendar. Almost always the same as
140     *    principalUri passed to this method.
141     *
142     * Furthermore it can contain webdav properties in clark notation. A very
143     * common one is '{DAV:}displayname'.
144     *
145     * Many clients also require:
146     * {urn:ietf:params:xml:ns:caldav}supported-calendar-component-set
147     * For this property, you can just return an instance of
148     * Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet.
149     *
150     * If you return {http://sabredav.org/ns}read-only and set the value to 1,
151     * ACL will automatically be put in read-only mode.
152     *
153     * @param string $principalUri
154     * @return array
155     */
156    function getCalendarsForUser($principalUri) {
157
158        $fields = array_values($this->propertyMap);
159        $fields[] = 'calendarid';
160        $fields[] = 'uri';
161        $fields[] = 'synctoken';
162        $fields[] = 'components';
163        $fields[] = 'principaluri';
164        $fields[] = 'transparent';
165        $fields[] = 'access';
166
167        // Making fields a comma-delimited list
168        $fields = implode(', ', $fields);
169        $stmt = $this->pdo->prepare(<<<SQL
170SELECT {$this->calendarInstancesTableName}.id as id, $fields FROM {$this->calendarInstancesTableName}
171    LEFT JOIN {$this->calendarTableName} ON
172        {$this->calendarInstancesTableName}.calendarid = {$this->calendarTableName}.id
173WHERE principaluri = ? ORDER BY calendarorder ASC
174SQL
175        );
176        $stmt->execute([$principalUri]);
177
178        $calendars = [];
179        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
180
181            $components = [];
182            if ($row['components']) {
183                $components = explode(',', $row['components']);
184            }
185
186            $calendar = [
187                'id'                                                                 => [(int)$row['calendarid'], (int)$row['id']],
188                'uri'                                                                => $row['uri'],
189                'principaluri'                                                       => $row['principaluri'],
190                '{' . CalDAV\Plugin::NS_CALENDARSERVER . '}getctag'                  => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ? $row['synctoken'] : '0'),
191                '{http://sabredav.org/ns}sync-token'                                 => $row['synctoken'] ? $row['synctoken'] : '0',
192                '{' . CalDAV\Plugin::NS_CALDAV . '}supported-calendar-component-set' => new CalDAV\Xml\Property\SupportedCalendarComponentSet($components),
193                '{' . CalDAV\Plugin::NS_CALDAV . '}schedule-calendar-transp'         => new CalDAV\Xml\Property\ScheduleCalendarTransp($row['transparent'] ? 'transparent' : 'opaque'),
194                'share-resource-uri'                                                 => '/ns/share/' . $row['calendarid'],
195            ];
196
197            $calendar['share-access'] = (int)$row['access'];
198            // 1 = owner, 2 = readonly, 3 = readwrite
199            if ($row['access'] > 1) {
200                // We need to find more information about the original owner.
201                //$stmt2 = $this->pdo->prepare('SELECT principaluri FROM ' . $this->calendarInstancesTableName . ' WHERE access = 1 AND id = ?');
202                //$stmt2->execute([$row['id']]);
203
204                // read-only is for backwards compatbility. Might go away in
205                // the future.
206                $calendar['read-only'] = (int)$row['access'] === \Sabre\DAV\Sharing\Plugin::ACCESS_READ;
207            }
208
209            foreach ($this->propertyMap as $xmlName => $dbName) {
210                $calendar[$xmlName] = $row[$dbName];
211            }
212
213            $calendars[] = $calendar;
214
215        }
216
217        return $calendars;
218
219    }
220
221    /**
222     * Creates a new calendar for a principal.
223     *
224     * If the creation was a success, an id must be returned that can be used
225     * to reference this calendar in other methods, such as updateCalendar.
226     *
227     * @param string $principalUri
228     * @param string $calendarUri
229     * @param array $properties
230     * @return string
231     */
232    function createCalendar($principalUri, $calendarUri, array $properties) {
233
234        $fieldNames = [
235            'principaluri',
236            'uri',
237            'transparent',
238            'calendarid',
239        ];
240        $values = [
241            ':principaluri' => $principalUri,
242            ':uri'          => $calendarUri,
243            ':transparent'  => 0,
244        ];
245
246
247        $sccs = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set';
248        if (!isset($properties[$sccs])) {
249            // Default value
250            $components = 'VEVENT,VTODO';
251        } else {
252            if (!($properties[$sccs] instanceof CalDAV\Xml\Property\SupportedCalendarComponentSet)) {
253                throw new DAV\Exception('The ' . $sccs . ' property must be of type: \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet');
254            }
255            $components = implode(',', $properties[$sccs]->getValue());
256        }
257        $transp = '{' . CalDAV\Plugin::NS_CALDAV . '}schedule-calendar-transp';
258        if (isset($properties[$transp])) {
259            $values[':transparent'] = $properties[$transp]->getValue() === 'transparent' ? 1 : 0;
260        }
261        $stmt = $this->pdo->prepare("INSERT INTO " . $this->calendarTableName . " (synctoken, components) VALUES (1, ?)");
262        $stmt->execute([$components]);
263
264        $calendarId = $this->pdo->lastInsertId(
265            $this->calendarTableName . '_id_seq'
266        );
267
268        $values[':calendarid'] = $calendarId;
269
270        foreach ($this->propertyMap as $xmlName => $dbName) {
271            if (isset($properties[$xmlName])) {
272
273                $values[':' . $dbName] = $properties[$xmlName];
274                $fieldNames[] = $dbName;
275            }
276        }
277
278        $stmt = $this->pdo->prepare("INSERT INTO " . $this->calendarInstancesTableName . " (" . implode(', ', $fieldNames) . ") VALUES (" . implode(', ', array_keys($values)) . ")");
279
280        $stmt->execute($values);
281
282        return [
283            $calendarId,
284            $this->pdo->lastInsertId($this->calendarInstancesTableName . '_id_seq')
285        ];
286
287    }
288
289    /**
290     * Updates properties for a calendar.
291     *
292     * The list of mutations is stored in a Sabre\DAV\PropPatch object.
293     * To do the actual updates, you must tell this object which properties
294     * you're going to process with the handle() method.
295     *
296     * Calling the handle method is like telling the PropPatch object "I
297     * promise I can handle updating this property".
298     *
299     * Read the PropPatch documentation for more info and examples.
300     *
301     * @param mixed $calendarId
302     * @param \Sabre\DAV\PropPatch $propPatch
303     * @return void
304     */
305    function updateCalendar($calendarId, \Sabre\DAV\PropPatch $propPatch) {
306
307        if (!is_array($calendarId)) {
308            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
309        }
310        list($calendarId, $instanceId) = $calendarId;
311
312        $supportedProperties = array_keys($this->propertyMap);
313        $supportedProperties[] = '{' . CalDAV\Plugin::NS_CALDAV . '}schedule-calendar-transp';
314
315        $propPatch->handle($supportedProperties, function($mutations) use ($calendarId, $instanceId) {
316            $newValues = [];
317            foreach ($mutations as $propertyName => $propertyValue) {
318
319                switch ($propertyName) {
320                    case '{' . CalDAV\Plugin::NS_CALDAV . '}schedule-calendar-transp' :
321                        $fieldName = 'transparent';
322                        $newValues[$fieldName] = $propertyValue->getValue() === 'transparent';
323                        break;
324                    default :
325                        $fieldName = $this->propertyMap[$propertyName];
326                        $newValues[$fieldName] = $propertyValue;
327                        break;
328                }
329
330            }
331            $valuesSql = [];
332            foreach ($newValues as $fieldName => $value) {
333                $valuesSql[] = $fieldName . ' = ?';
334            }
335
336            $stmt = $this->pdo->prepare("UPDATE " . $this->calendarInstancesTableName . " SET " . implode(', ', $valuesSql) . " WHERE id = ?");
337            $newValues['id'] = $instanceId;
338            $stmt->execute(array_values($newValues));
339
340            $this->addChange($calendarId, "", 2);
341
342            return true;
343
344        });
345
346    }
347
348    /**
349     * Delete a calendar and all it's objects
350     *
351     * @param mixed $calendarId
352     * @return void
353     */
354    function deleteCalendar($calendarId) {
355
356        if (!is_array($calendarId)) {
357            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
358        }
359        list($calendarId, $instanceId) = $calendarId;
360
361        $stmt = $this->pdo->prepare('SELECT access FROM ' . $this->calendarInstancesTableName . ' where id = ?');
362        $stmt->execute([$instanceId]);
363        $access = (int)$stmt->fetchColumn();
364
365        if ($access === \Sabre\DAV\Sharing\Plugin::ACCESS_SHAREDOWNER) {
366
367            /**
368             * If the user is the owner of the calendar, we delete all data and all
369             * instances.
370             **/
371            $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarObjectTableName . ' WHERE calendarid = ?');
372            $stmt->execute([$calendarId]);
373
374            $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarChangesTableName . ' WHERE calendarid = ?');
375            $stmt->execute([$calendarId]);
376
377            $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarInstancesTableName . ' WHERE calendarid = ?');
378            $stmt->execute([$calendarId]);
379
380            $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarTableName . ' WHERE id = ?');
381            $stmt->execute([$calendarId]);
382
383        } else {
384
385            /**
386             * If it was an instance of a shared calendar, we only delete that
387             * instance.
388             */
389            $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarInstancesTableName . ' WHERE id = ?');
390            $stmt->execute([$instanceId]);
391
392        }
393
394
395    }
396
397    /**
398     * Returns all calendar objects within a calendar.
399     *
400     * Every item contains an array with the following keys:
401     *   * calendardata - The iCalendar-compatible calendar data
402     *   * uri - a unique key which will be used to construct the uri. This can
403     *     be any arbitrary string, but making sure it ends with '.ics' is a
404     *     good idea. This is only the basename, or filename, not the full
405     *     path.
406     *   * lastmodified - a timestamp of the last modification time
407     *   * etag - An arbitrary string, surrounded by double-quotes. (e.g.:
408     *   '  "abcdef"')
409     *   * size - The size of the calendar objects, in bytes.
410     *   * component - optional, a string containing the type of object, such
411     *     as 'vevent' or 'vtodo'. If specified, this will be used to populate
412     *     the Content-Type header.
413     *
414     * Note that the etag is optional, but it's highly encouraged to return for
415     * speed reasons.
416     *
417     * The calendardata is also optional. If it's not returned
418     * 'getCalendarObject' will be called later, which *is* expected to return
419     * calendardata.
420     *
421     * If neither etag or size are specified, the calendardata will be
422     * used/fetched to determine these numbers. If both are specified the
423     * amount of times this is needed is reduced by a great degree.
424     *
425     * @param mixed $calendarId
426     * @return array
427     */
428    function getCalendarObjects($calendarId) {
429
430        if (!is_array($calendarId)) {
431            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
432        }
433        list($calendarId, $instanceId) = $calendarId;
434
435        $stmt = $this->pdo->prepare('SELECT id, uri, lastmodified, etag, calendarid, size, componenttype FROM ' . $this->calendarObjectTableName . ' WHERE calendarid = ?');
436        $stmt->execute([$calendarId]);
437
438        $result = [];
439        foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
440            $result[] = [
441                'id'           => $row['id'],
442                'uri'          => $row['uri'],
443                'lastmodified' => (int)$row['lastmodified'],
444                'etag'         => '"' . $row['etag'] . '"',
445                'size'         => (int)$row['size'],
446                'component'    => strtolower($row['componenttype']),
447            ];
448        }
449
450        return $result;
451
452    }
453
454    /**
455     * Returns information from a single calendar object, based on it's object
456     * uri.
457     *
458     * The object uri is only the basename, or filename and not a full path.
459     *
460     * The returned array must have the same keys as getCalendarObjects. The
461     * 'calendardata' object is required here though, while it's not required
462     * for getCalendarObjects.
463     *
464     * This method must return null if the object did not exist.
465     *
466     * @param mixed $calendarId
467     * @param string $objectUri
468     * @return array|null
469     */
470    function getCalendarObject($calendarId, $objectUri) {
471
472        if (!is_array($calendarId)) {
473            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
474        }
475        list($calendarId, $instanceId) = $calendarId;
476
477        $stmt = $this->pdo->prepare('SELECT id, uri, lastmodified, etag, calendarid, size, calendardata, componenttype FROM ' . $this->calendarObjectTableName . ' WHERE calendarid = ? AND uri = ?');
478        $stmt->execute([$calendarId, $objectUri]);
479        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
480
481        if (!$row) return null;
482
483        return [
484            'id'           => $row['id'],
485            'uri'          => $row['uri'],
486            'lastmodified' => (int)$row['lastmodified'],
487            'etag'         => '"' . $row['etag'] . '"',
488            'size'         => (int)$row['size'],
489            'calendardata' => $row['calendardata'],
490            'component'    => strtolower($row['componenttype']),
491         ];
492
493    }
494
495    /**
496     * Returns a list of calendar objects.
497     *
498     * This method should work identical to getCalendarObject, but instead
499     * return all the calendar objects in the list as an array.
500     *
501     * If the backend supports this, it may allow for some speed-ups.
502     *
503     * @param mixed $calendarId
504     * @param array $uris
505     * @return array
506     */
507    function getMultipleCalendarObjects($calendarId, array $uris) {
508
509        if (!is_array($calendarId)) {
510            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
511        }
512        list($calendarId, $instanceId) = $calendarId;
513
514        $result = [];
515        foreach (array_chunk($uris, 900) as $chunk) {
516            $query = 'SELECT id, uri, lastmodified, etag, calendarid, size, calendardata, componenttype FROM ' . $this->calendarObjectTableName . ' WHERE calendarid = ? AND uri IN (';
517            // Inserting a whole bunch of question marks
518            $query .= implode(',', array_fill(0, count($chunk), '?'));
519            $query .= ')';
520
521            $stmt = $this->pdo->prepare($query);
522            $stmt->execute(array_merge([$calendarId], $chunk));
523
524            while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
525
526                $result[] = [
527                    'id'           => $row['id'],
528                    'uri'          => $row['uri'],
529                    'lastmodified' => (int)$row['lastmodified'],
530                    'etag'         => '"' . $row['etag'] . '"',
531                    'size'         => (int)$row['size'],
532                    'calendardata' => $row['calendardata'],
533                    'component'    => strtolower($row['componenttype']),
534                ];
535
536            }
537        }
538        return $result;
539
540    }
541
542
543    /**
544     * Creates a new calendar object.
545     *
546     * The object uri is only the basename, or filename and not a full path.
547     *
548     * It is possible return an etag from this function, which will be used in
549     * the response to this PUT request. Note that the ETag must be surrounded
550     * by double-quotes.
551     *
552     * However, you should only really return this ETag if you don't mangle the
553     * calendar-data. If the result of a subsequent GET to this object is not
554     * the exact same as this request body, you should omit the ETag.
555     *
556     * @param mixed $calendarId
557     * @param string $objectUri
558     * @param string $calendarData
559     * @return string|null
560     */
561    function createCalendarObject($calendarId, $objectUri, $calendarData) {
562
563        if (!is_array($calendarId)) {
564            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
565        }
566        list($calendarId, $instanceId) = $calendarId;
567
568        $extraData = $this->getDenormalizedData($calendarData);
569
570        $stmt = $this->pdo->prepare('INSERT INTO ' . $this->calendarObjectTableName . ' (calendarid, uri, calendardata, lastmodified, etag, size, componenttype, firstoccurence, lastoccurence, uid) VALUES (?,?,?,?,?,?,?,?,?,?)');
571        $stmt->execute([
572            $calendarId,
573            $objectUri,
574            $calendarData,
575            time(),
576            $extraData['etag'],
577            $extraData['size'],
578            $extraData['componentType'],
579            $extraData['firstOccurence'],
580            $extraData['lastOccurence'],
581            $extraData['uid'],
582        ]);
583        $this->addChange($calendarId, $objectUri, 1);
584
585        return '"' . $extraData['etag'] . '"';
586
587    }
588
589    /**
590     * Updates an existing calendarobject, based on it's uri.
591     *
592     * The object uri is only the basename, or filename and not a full path.
593     *
594     * It is possible return an etag from this function, which will be used in
595     * the response to this PUT request. Note that the ETag must be surrounded
596     * by double-quotes.
597     *
598     * However, you should only really return this ETag if you don't mangle the
599     * calendar-data. If the result of a subsequent GET to this object is not
600     * the exact same as this request body, you should omit the ETag.
601     *
602     * @param mixed $calendarId
603     * @param string $objectUri
604     * @param string $calendarData
605     * @return string|null
606     */
607    function updateCalendarObject($calendarId, $objectUri, $calendarData) {
608
609        if (!is_array($calendarId)) {
610            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
611        }
612        list($calendarId, $instanceId) = $calendarId;
613
614        $extraData = $this->getDenormalizedData($calendarData);
615
616        $stmt = $this->pdo->prepare('UPDATE ' . $this->calendarObjectTableName . ' SET calendardata = ?, lastmodified = ?, etag = ?, size = ?, componenttype = ?, firstoccurence = ?, lastoccurence = ?, uid = ? WHERE calendarid = ? AND uri = ?');
617        $stmt->execute([$calendarData, time(), $extraData['etag'], $extraData['size'], $extraData['componentType'], $extraData['firstOccurence'], $extraData['lastOccurence'], $extraData['uid'], $calendarId, $objectUri]);
618
619        $this->addChange($calendarId, $objectUri, 2);
620
621        return '"' . $extraData['etag'] . '"';
622
623    }
624
625    /**
626     * Parses some information from calendar objects, used for optimized
627     * calendar-queries.
628     *
629     * Returns an array with the following keys:
630     *   * etag - An md5 checksum of the object without the quotes.
631     *   * size - Size of the object in bytes
632     *   * componentType - VEVENT, VTODO or VJOURNAL
633     *   * firstOccurence
634     *   * lastOccurence
635     *   * uid - value of the UID property
636     *
637     * @param string $calendarData
638     * @return array
639     */
640    protected function getDenormalizedData($calendarData) {
641
642        $vObject = VObject\Reader::read($calendarData);
643        $componentType = null;
644        $component = null;
645        $firstOccurence = null;
646        $lastOccurence = null;
647        $uid = null;
648        foreach ($vObject->getComponents() as $component) {
649            if ($component->name !== 'VTIMEZONE') {
650                $componentType = $component->name;
651                $uid = (string)$component->UID;
652                break;
653            }
654        }
655        if (!$componentType) {
656            throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component');
657        }
658        if ($componentType === 'VEVENT') {
659            $firstOccurence = $component->DTSTART->getDateTime()->getTimeStamp();
660            // Finding the last occurence is a bit harder
661            if (!isset($component->RRULE)) {
662                if (isset($component->DTEND)) {
663                    $lastOccurence = $component->DTEND->getDateTime()->getTimeStamp();
664                } elseif (isset($component->DURATION)) {
665                    $endDate = clone $component->DTSTART->getDateTime();
666                    $endDate = $endDate->add(VObject\DateTimeParser::parse($component->DURATION->getValue()));
667                    $lastOccurence = $endDate->getTimeStamp();
668                } elseif (!$component->DTSTART->hasTime()) {
669                    $endDate = clone $component->DTSTART->getDateTime();
670                    $endDate = $endDate->modify('+1 day');
671                    $lastOccurence = $endDate->getTimeStamp();
672                } else {
673                    $lastOccurence = $firstOccurence;
674                }
675            } else {
676                $it = new VObject\Recur\EventIterator($vObject, (string)$component->UID);
677                $maxDate = new \DateTime(self::MAX_DATE);
678                if ($it->isInfinite()) {
679                    $lastOccurence = $maxDate->getTimeStamp();
680                } else {
681                    $end = $it->getDtEnd();
682                    while ($it->valid() && $end < $maxDate) {
683                        $end = $it->getDtEnd();
684                        $it->next();
685
686                    }
687                    $lastOccurence = $end->getTimeStamp();
688                }
689
690            }
691
692            // Ensure Occurence values are positive
693            if ($firstOccurence < 0) $firstOccurence = 0;
694            if ($lastOccurence < 0) $lastOccurence = 0;
695        }
696
697        // Destroy circular references to PHP will GC the object.
698        $vObject->destroy();
699
700        return [
701            'etag'           => md5($calendarData),
702            'size'           => strlen($calendarData),
703            'componentType'  => $componentType,
704            'firstOccurence' => $firstOccurence,
705            'lastOccurence'  => $lastOccurence,
706            'uid'            => $uid,
707        ];
708
709    }
710
711    /**
712     * Deletes an existing calendar object.
713     *
714     * The object uri is only the basename, or filename and not a full path.
715     *
716     * @param mixed $calendarId
717     * @param string $objectUri
718     * @return void
719     */
720    function deleteCalendarObject($calendarId, $objectUri) {
721
722        if (!is_array($calendarId)) {
723            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
724        }
725        list($calendarId, $instanceId) = $calendarId;
726
727        $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarObjectTableName . ' WHERE calendarid = ? AND uri = ?');
728        $stmt->execute([$calendarId, $objectUri]);
729
730        $this->addChange($calendarId, $objectUri, 3);
731
732    }
733
734    /**
735     * Performs a calendar-query on the contents of this calendar.
736     *
737     * The calendar-query is defined in RFC4791 : CalDAV. Using the
738     * calendar-query it is possible for a client to request a specific set of
739     * object, based on contents of iCalendar properties, date-ranges and
740     * iCalendar component types (VTODO, VEVENT).
741     *
742     * This method should just return a list of (relative) urls that match this
743     * query.
744     *
745     * The list of filters are specified as an array. The exact array is
746     * documented by \Sabre\CalDAV\CalendarQueryParser.
747     *
748     * Note that it is extremely likely that getCalendarObject for every path
749     * returned from this method will be called almost immediately after. You
750     * may want to anticipate this to speed up these requests.
751     *
752     * This method provides a default implementation, which parses *all* the
753     * iCalendar objects in the specified calendar.
754     *
755     * This default may well be good enough for personal use, and calendars
756     * that aren't very large. But if you anticipate high usage, big calendars
757     * or high loads, you are strongly adviced to optimize certain paths.
758     *
759     * The best way to do so is override this method and to optimize
760     * specifically for 'common filters'.
761     *
762     * Requests that are extremely common are:
763     *   * requests for just VEVENTS
764     *   * requests for just VTODO
765     *   * requests with a time-range-filter on a VEVENT.
766     *
767     * ..and combinations of these requests. It may not be worth it to try to
768     * handle every possible situation and just rely on the (relatively
769     * easy to use) CalendarQueryValidator to handle the rest.
770     *
771     * Note that especially time-range-filters may be difficult to parse. A
772     * time-range filter specified on a VEVENT must for instance also handle
773     * recurrence rules correctly.
774     * A good example of how to interpret all these filters can also simply
775     * be found in \Sabre\CalDAV\CalendarQueryFilter. This class is as correct
776     * as possible, so it gives you a good idea on what type of stuff you need
777     * to think of.
778     *
779     * This specific implementation (for the PDO) backend optimizes filters on
780     * specific components, and VEVENT time-ranges.
781     *
782     * @param mixed $calendarId
783     * @param array $filters
784     * @return array
785     */
786    function calendarQuery($calendarId, array $filters) {
787
788        if (!is_array($calendarId)) {
789            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
790        }
791        list($calendarId, $instanceId) = $calendarId;
792
793        $componentType = null;
794        $requirePostFilter = true;
795        $timeRange = null;
796
797        // if no filters were specified, we don't need to filter after a query
798        if (!$filters['prop-filters'] && !$filters['comp-filters']) {
799            $requirePostFilter = false;
800        }
801
802        // Figuring out if there's a component filter
803        if (count($filters['comp-filters']) > 0 && !$filters['comp-filters'][0]['is-not-defined']) {
804            $componentType = $filters['comp-filters'][0]['name'];
805
806            // Checking if we need post-filters
807            if (!$filters['prop-filters'] && !$filters['comp-filters'][0]['comp-filters'] && !$filters['comp-filters'][0]['time-range'] && !$filters['comp-filters'][0]['prop-filters']) {
808                $requirePostFilter = false;
809            }
810            // There was a time-range filter
811            if ($componentType == 'VEVENT' && isset($filters['comp-filters'][0]['time-range'])) {
812                $timeRange = $filters['comp-filters'][0]['time-range'];
813
814                // If start time OR the end time is not specified, we can do a
815                // 100% accurate mysql query.
816                if (!$filters['prop-filters'] && !$filters['comp-filters'][0]['comp-filters'] && !$filters['comp-filters'][0]['prop-filters'] && (!$timeRange['start'] || !$timeRange['end'])) {
817                    $requirePostFilter = false;
818                }
819            }
820
821        }
822
823        if ($requirePostFilter) {
824            $query = "SELECT uri, calendardata FROM " . $this->calendarObjectTableName . " WHERE calendarid = :calendarid";
825        } else {
826            $query = "SELECT uri FROM " . $this->calendarObjectTableName . " WHERE calendarid = :calendarid";
827        }
828
829        $values = [
830            'calendarid' => $calendarId,
831        ];
832
833        if ($componentType) {
834            $query .= " AND componenttype = :componenttype";
835            $values['componenttype'] = $componentType;
836        }
837
838        if ($timeRange && $timeRange['start']) {
839            $query .= " AND lastoccurence > :startdate";
840            $values['startdate'] = $timeRange['start']->getTimeStamp();
841        }
842        if ($timeRange && $timeRange['end']) {
843            $query .= " AND firstoccurence < :enddate";
844            $values['enddate'] = $timeRange['end']->getTimeStamp();
845        }
846
847        $stmt = $this->pdo->prepare($query);
848        $stmt->execute($values);
849
850        $result = [];
851        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
852            if ($requirePostFilter) {
853                if (!$this->validateFilterForObject($row, $filters)) {
854                    continue;
855                }
856            }
857            $result[] = $row['uri'];
858
859        }
860
861        return $result;
862
863    }
864
865    /**
866     * Searches through all of a users calendars and calendar objects to find
867     * an object with a specific UID.
868     *
869     * This method should return the path to this object, relative to the
870     * calendar home, so this path usually only contains two parts:
871     *
872     * calendarpath/objectpath.ics
873     *
874     * If the uid is not found, return null.
875     *
876     * This method should only consider * objects that the principal owns, so
877     * any calendars owned by other principals that also appear in this
878     * collection should be ignored.
879     *
880     * @param string $principalUri
881     * @param string $uid
882     * @return string|null
883     */
884    function getCalendarObjectByUID($principalUri, $uid) {
885
886        $query = <<<SQL
887SELECT
888    calendar_instances.uri AS calendaruri, calendarobjects.uri as objecturi
889FROM
890    $this->calendarObjectTableName AS calendarobjects
891LEFT JOIN
892    $this->calendarInstancesTableName AS calendar_instances
893    ON calendarobjects.calendarid = calendar_instances.calendarid
894WHERE
895    calendar_instances.principaluri = ?
896    AND
897    calendarobjects.uid = ?
898SQL;
899
900        $stmt = $this->pdo->prepare($query);
901        $stmt->execute([$principalUri, $uid]);
902
903        if ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
904            return $row['calendaruri'] . '/' . $row['objecturi'];
905        }
906
907    }
908
909    /**
910     * The getChanges method returns all the changes that have happened, since
911     * the specified syncToken in the specified calendar.
912     *
913     * This function should return an array, such as the following:
914     *
915     * [
916     *   'syncToken' => 'The current synctoken',
917     *   'added'   => [
918     *      'new.txt',
919     *   ],
920     *   'modified'   => [
921     *      'modified.txt',
922     *   ],
923     *   'deleted' => [
924     *      'foo.php.bak',
925     *      'old.txt'
926     *   ]
927     * ];
928     *
929     * The returned syncToken property should reflect the *current* syncToken
930     * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
931     * property this is needed here too, to ensure the operation is atomic.
932     *
933     * If the $syncToken argument is specified as null, this is an initial
934     * sync, and all members should be reported.
935     *
936     * The modified property is an array of nodenames that have changed since
937     * the last token.
938     *
939     * The deleted property is an array with nodenames, that have been deleted
940     * from collection.
941     *
942     * The $syncLevel argument is basically the 'depth' of the report. If it's
943     * 1, you only have to report changes that happened only directly in
944     * immediate descendants. If it's 2, it should also include changes from
945     * the nodes below the child collections. (grandchildren)
946     *
947     * The $limit argument allows a client to specify how many results should
948     * be returned at most. If the limit is not specified, it should be treated
949     * as infinite.
950     *
951     * If the limit (infinite or not) is higher than you're willing to return,
952     * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
953     *
954     * If the syncToken is expired (due to data cleanup) or unknown, you must
955     * return null.
956     *
957     * The limit is 'suggestive'. You are free to ignore it.
958     *
959     * @param mixed $calendarId
960     * @param string $syncToken
961     * @param int $syncLevel
962     * @param int $limit
963     * @return array
964     */
965    function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null) {
966
967        if (!is_array($calendarId)) {
968            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
969        }
970        list($calendarId, $instanceId) = $calendarId;
971
972        // Current synctoken
973        $stmt = $this->pdo->prepare('SELECT synctoken FROM ' . $this->calendarTableName . ' WHERE id = ?');
974        $stmt->execute([$calendarId]);
975        $currentToken = $stmt->fetchColumn(0);
976
977        if (is_null($currentToken)) return null;
978
979        $result = [
980            'syncToken' => $currentToken,
981            'added'     => [],
982            'modified'  => [],
983            'deleted'   => [],
984        ];
985
986        if ($syncToken) {
987
988            $query = "SELECT uri, operation FROM " . $this->calendarChangesTableName . " WHERE synctoken >= ? AND synctoken < ? AND calendarid = ? ORDER BY synctoken";
989            if ($limit > 0) $query .= " LIMIT " . (int)$limit;
990
991            // Fetching all changes
992            $stmt = $this->pdo->prepare($query);
993            $stmt->execute([$syncToken, $currentToken, $calendarId]);
994
995            $changes = [];
996
997            // This loop ensures that any duplicates are overwritten, only the
998            // last change on a node is relevant.
999            while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
1000
1001                $changes[$row['uri']] = $row['operation'];
1002
1003            }
1004
1005            foreach ($changes as $uri => $operation) {
1006
1007                switch ($operation) {
1008                    case 1 :
1009                        $result['added'][] = $uri;
1010                        break;
1011                    case 2 :
1012                        $result['modified'][] = $uri;
1013                        break;
1014                    case 3 :
1015                        $result['deleted'][] = $uri;
1016                        break;
1017                }
1018
1019            }
1020        } else {
1021            // No synctoken supplied, this is the initial sync.
1022            $query = "SELECT uri FROM " . $this->calendarObjectTableName . " WHERE calendarid = ?";
1023            $stmt = $this->pdo->prepare($query);
1024            $stmt->execute([$calendarId]);
1025
1026            $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
1027        }
1028        return $result;
1029
1030    }
1031
1032    /**
1033     * Adds a change record to the calendarchanges table.
1034     *
1035     * @param mixed $calendarId
1036     * @param string $objectUri
1037     * @param int $operation 1 = add, 2 = modify, 3 = delete.
1038     * @return void
1039     */
1040    protected function addChange($calendarId, $objectUri, $operation) {
1041
1042        $stmt = $this->pdo->prepare('INSERT INTO ' . $this->calendarChangesTableName . ' (uri, synctoken, calendarid, operation) SELECT ?, synctoken, ?, ? FROM ' . $this->calendarTableName . ' WHERE id = ?');
1043        $stmt->execute([
1044            $objectUri,
1045            $calendarId,
1046            $operation,
1047            $calendarId
1048        ]);
1049        $stmt = $this->pdo->prepare('UPDATE ' . $this->calendarTableName . ' SET synctoken = synctoken + 1 WHERE id = ?');
1050        $stmt->execute([
1051            $calendarId
1052        ]);
1053
1054    }
1055
1056    /**
1057     * Returns a list of subscriptions for a principal.
1058     *
1059     * Every subscription is an array with the following keys:
1060     *  * id, a unique id that will be used by other functions to modify the
1061     *    subscription. This can be the same as the uri or a database key.
1062     *  * uri. This is just the 'base uri' or 'filename' of the subscription.
1063     *  * principaluri. The owner of the subscription. Almost always the same as
1064     *    principalUri passed to this method.
1065     *  * source. Url to the actual feed
1066     *
1067     * Furthermore, all the subscription info must be returned too:
1068     *
1069     * 1. {DAV:}displayname
1070     * 2. {http://apple.com/ns/ical/}refreshrate
1071     * 3. {http://calendarserver.org/ns/}subscribed-strip-todos (omit if todos
1072     *    should not be stripped).
1073     * 4. {http://calendarserver.org/ns/}subscribed-strip-alarms (omit if alarms
1074     *    should not be stripped).
1075     * 5. {http://calendarserver.org/ns/}subscribed-strip-attachments (omit if
1076     *    attachments should not be stripped).
1077     * 7. {http://apple.com/ns/ical/}calendar-color
1078     * 8. {http://apple.com/ns/ical/}calendar-order
1079     * 9. {urn:ietf:params:xml:ns:caldav}supported-calendar-component-set
1080     *    (should just be an instance of
1081     *    Sabre\CalDAV\Property\SupportedCalendarComponentSet, with a bunch of
1082     *    default components).
1083     *
1084     * @param string $principalUri
1085     * @return array
1086     */
1087    function getSubscriptionsForUser($principalUri) {
1088
1089        $fields = array_values($this->subscriptionPropertyMap);
1090        $fields[] = 'id';
1091        $fields[] = 'uri';
1092        $fields[] = 'source';
1093        $fields[] = 'principaluri';
1094        $fields[] = 'lastmodified';
1095
1096        // Making fields a comma-delimited list
1097        $fields = implode(', ', $fields);
1098        $stmt = $this->pdo->prepare("SELECT " . $fields . " FROM " . $this->calendarSubscriptionsTableName . " WHERE principaluri = ? ORDER BY calendarorder ASC");
1099        $stmt->execute([$principalUri]);
1100
1101        $subscriptions = [];
1102        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
1103
1104            $subscription = [
1105                'id'           => $row['id'],
1106                'uri'          => $row['uri'],
1107                'principaluri' => $row['principaluri'],
1108                'source'       => $row['source'],
1109                'lastmodified' => $row['lastmodified'],
1110
1111                '{' . CalDAV\Plugin::NS_CALDAV . '}supported-calendar-component-set' => new CalDAV\Xml\Property\SupportedCalendarComponentSet(['VTODO', 'VEVENT']),
1112            ];
1113
1114            foreach ($this->subscriptionPropertyMap as $xmlName => $dbName) {
1115                if (!is_null($row[$dbName])) {
1116                    $subscription[$xmlName] = $row[$dbName];
1117                }
1118            }
1119
1120            $subscriptions[] = $subscription;
1121
1122        }
1123
1124        return $subscriptions;
1125
1126    }
1127
1128    /**
1129     * Creates a new subscription for a principal.
1130     *
1131     * If the creation was a success, an id must be returned that can be used to reference
1132     * this subscription in other methods, such as updateSubscription.
1133     *
1134     * @param string $principalUri
1135     * @param string $uri
1136     * @param array $properties
1137     * @return mixed
1138     */
1139    function createSubscription($principalUri, $uri, array $properties) {
1140
1141        $fieldNames = [
1142            'principaluri',
1143            'uri',
1144            'source',
1145            'lastmodified',
1146        ];
1147
1148        if (!isset($properties['{http://calendarserver.org/ns/}source'])) {
1149            throw new Forbidden('The {http://calendarserver.org/ns/}source property is required when creating subscriptions');
1150        }
1151
1152        $values = [
1153            ':principaluri' => $principalUri,
1154            ':uri'          => $uri,
1155            ':source'       => $properties['{http://calendarserver.org/ns/}source']->getHref(),
1156            ':lastmodified' => time(),
1157        ];
1158
1159        foreach ($this->subscriptionPropertyMap as $xmlName => $dbName) {
1160            if (isset($properties[$xmlName])) {
1161
1162                $values[':' . $dbName] = $properties[$xmlName];
1163                $fieldNames[] = $dbName;
1164            }
1165        }
1166
1167        $stmt = $this->pdo->prepare("INSERT INTO " . $this->calendarSubscriptionsTableName . " (" . implode(', ', $fieldNames) . ") VALUES (" . implode(', ', array_keys($values)) . ")");
1168        $stmt->execute($values);
1169
1170        return $this->pdo->lastInsertId(
1171            $this->calendarSubscriptionsTableName . '_id_seq'
1172        );
1173
1174    }
1175
1176    /**
1177     * Updates a subscription
1178     *
1179     * The list of mutations is stored in a Sabre\DAV\PropPatch object.
1180     * To do the actual updates, you must tell this object which properties
1181     * you're going to process with the handle() method.
1182     *
1183     * Calling the handle method is like telling the PropPatch object "I
1184     * promise I can handle updating this property".
1185     *
1186     * Read the PropPatch documentation for more info and examples.
1187     *
1188     * @param mixed $subscriptionId
1189     * @param \Sabre\DAV\PropPatch $propPatch
1190     * @return void
1191     */
1192    function updateSubscription($subscriptionId, DAV\PropPatch $propPatch) {
1193
1194        $supportedProperties = array_keys($this->subscriptionPropertyMap);
1195        $supportedProperties[] = '{http://calendarserver.org/ns/}source';
1196
1197        $propPatch->handle($supportedProperties, function($mutations) use ($subscriptionId) {
1198
1199            $newValues = [];
1200
1201            foreach ($mutations as $propertyName => $propertyValue) {
1202
1203                if ($propertyName === '{http://calendarserver.org/ns/}source') {
1204                    $newValues['source'] = $propertyValue->getHref();
1205                } else {
1206                    $fieldName = $this->subscriptionPropertyMap[$propertyName];
1207                    $newValues[$fieldName] = $propertyValue;
1208                }
1209
1210            }
1211
1212            // Now we're generating the sql query.
1213            $valuesSql = [];
1214            foreach ($newValues as $fieldName => $value) {
1215                $valuesSql[] = $fieldName . ' = ?';
1216            }
1217
1218            $stmt = $this->pdo->prepare("UPDATE " . $this->calendarSubscriptionsTableName . " SET " . implode(', ', $valuesSql) . ", lastmodified = ? WHERE id = ?");
1219            $newValues['lastmodified'] = time();
1220            $newValues['id'] = $subscriptionId;
1221            $stmt->execute(array_values($newValues));
1222
1223            return true;
1224
1225        });
1226
1227    }
1228
1229    /**
1230     * Deletes a subscription
1231     *
1232     * @param mixed $subscriptionId
1233     * @return void
1234     */
1235    function deleteSubscription($subscriptionId) {
1236
1237        $stmt = $this->pdo->prepare('DELETE FROM ' . $this->calendarSubscriptionsTableName . ' WHERE id = ?');
1238        $stmt->execute([$subscriptionId]);
1239
1240    }
1241
1242    /**
1243     * Returns a single scheduling object.
1244     *
1245     * The returned array should contain the following elements:
1246     *   * uri - A unique basename for the object. This will be used to
1247     *           construct a full uri.
1248     *   * calendardata - The iCalendar object
1249     *   * lastmodified - The last modification date. Can be an int for a unix
1250     *                    timestamp, or a PHP DateTime object.
1251     *   * etag - A unique token that must change if the object changed.
1252     *   * size - The size of the object, in bytes.
1253     *
1254     * @param string $principalUri
1255     * @param string $objectUri
1256     * @return array
1257     */
1258    function getSchedulingObject($principalUri, $objectUri) {
1259
1260        $stmt = $this->pdo->prepare('SELECT uri, calendardata, lastmodified, etag, size FROM ' . $this->schedulingObjectTableName . ' WHERE principaluri = ? AND uri = ?');
1261        $stmt->execute([$principalUri, $objectUri]);
1262        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
1263
1264        if (!$row) return null;
1265
1266        return [
1267            'uri'          => $row['uri'],
1268            'calendardata' => $row['calendardata'],
1269            'lastmodified' => $row['lastmodified'],
1270            'etag'         => '"' . $row['etag'] . '"',
1271            'size'         => (int)$row['size'],
1272         ];
1273
1274    }
1275
1276    /**
1277     * Returns all scheduling objects for the inbox collection.
1278     *
1279     * These objects should be returned as an array. Every item in the array
1280     * should follow the same structure as returned from getSchedulingObject.
1281     *
1282     * The main difference is that 'calendardata' is optional.
1283     *
1284     * @param string $principalUri
1285     * @return array
1286     */
1287    function getSchedulingObjects($principalUri) {
1288
1289        $stmt = $this->pdo->prepare('SELECT id, calendardata, uri, lastmodified, etag, size FROM ' . $this->schedulingObjectTableName . ' WHERE principaluri = ?');
1290        $stmt->execute([$principalUri]);
1291
1292        $result = [];
1293        foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
1294            $result[] = [
1295                'calendardata' => $row['calendardata'],
1296                'uri'          => $row['uri'],
1297                'lastmodified' => $row['lastmodified'],
1298                'etag'         => '"' . $row['etag'] . '"',
1299                'size'         => (int)$row['size'],
1300            ];
1301        }
1302
1303        return $result;
1304
1305    }
1306
1307    /**
1308     * Deletes a scheduling object
1309     *
1310     * @param string $principalUri
1311     * @param string $objectUri
1312     * @return void
1313     */
1314    function deleteSchedulingObject($principalUri, $objectUri) {
1315
1316        $stmt = $this->pdo->prepare('DELETE FROM ' . $this->schedulingObjectTableName . ' WHERE principaluri = ? AND uri = ?');
1317        $stmt->execute([$principalUri, $objectUri]);
1318
1319    }
1320
1321    /**
1322     * Creates a new scheduling object. This should land in a users' inbox.
1323     *
1324     * @param string $principalUri
1325     * @param string $objectUri
1326     * @param string $objectData
1327     * @return void
1328     */
1329    function createSchedulingObject($principalUri, $objectUri, $objectData) {
1330
1331        $stmt = $this->pdo->prepare('INSERT INTO ' . $this->schedulingObjectTableName . ' (principaluri, calendardata, uri, lastmodified, etag, size) VALUES (?, ?, ?, ?, ?, ?)');
1332        $stmt->execute([$principalUri, $objectData, $objectUri, time(), md5($objectData), strlen($objectData)]);
1333
1334    }
1335
1336    /**
1337     * Updates the list of shares.
1338     *
1339     * @param mixed $calendarId
1340     * @param \Sabre\DAV\Xml\Element\Sharee[] $sharees
1341     * @return void
1342     */
1343    function updateInvites($calendarId, array $sharees) {
1344
1345        if (!is_array($calendarId)) {
1346            throw new \InvalidArgumentException('The value passed to $calendarId is expected to be an array with a calendarId and an instanceId');
1347        }
1348        $currentInvites = $this->getInvites($calendarId);
1349        list($calendarId, $instanceId) = $calendarId;
1350
1351        $removeStmt = $this->pdo->prepare("DELETE FROM " . $this->calendarInstancesTableName . " WHERE calendarid = ? AND share_href = ? AND access IN (2,3)");
1352        $updateStmt = $this->pdo->prepare("UPDATE " . $this->calendarInstancesTableName . " SET access = ?, share_displayname = ?, share_invitestatus = ? WHERE calendarid = ? AND share_href = ?");
1353
1354        $insertStmt = $this->pdo->prepare('
1355INSERT INTO ' . $this->calendarInstancesTableName . '
1356    (
1357        calendarid,
1358        principaluri,
1359        access,
1360        displayname,
1361        uri,
1362        description,
1363        calendarorder,
1364        calendarcolor,
1365        timezone,
1366        transparent,
1367        share_href,
1368        share_displayname,
1369        share_invitestatus
1370    )
1371    SELECT
1372        ?,
1373        ?,
1374        ?,
1375        displayname,
1376        ?,
1377        description,
1378        calendarorder,
1379        calendarcolor,
1380        timezone,
1381        1,
1382        ?,
1383        ?,
1384        ?
1385    FROM ' . $this->calendarInstancesTableName . ' WHERE id = ?');
1386
1387        foreach ($sharees as $sharee) {
1388
1389            if ($sharee->access === \Sabre\DAV\Sharing\Plugin::ACCESS_NOACCESS) {
1390                // if access was set no NOACCESS, it means access for an
1391                // existing sharee was removed.
1392                $removeStmt->execute([$calendarId, $sharee->href]);
1393                continue;
1394            }
1395
1396            if (is_null($sharee->principal)) {
1397                // If the server could not determine the principal automatically,
1398                // we will mark the invite status as invalid.
1399                $sharee->inviteStatus = \Sabre\DAV\Sharing\Plugin::INVITE_INVALID;
1400            } else {
1401                // Because sabre/dav does not yet have an invitation system,
1402                // every invite is automatically accepted for now.
1403                $sharee->inviteStatus = \Sabre\DAV\Sharing\Plugin::INVITE_ACCEPTED;
1404            }
1405
1406            foreach ($currentInvites as $oldSharee) {
1407
1408                if ($oldSharee->href === $sharee->href) {
1409                    // This is an update
1410                    $sharee->properties = array_merge(
1411                        $oldSharee->properties,
1412                        $sharee->properties
1413                    );
1414                    $updateStmt->execute([
1415                        $sharee->access,
1416                        isset($sharee->properties['{DAV:}displayname']) ? $sharee->properties['{DAV:}displayname'] : null,
1417                        $sharee->inviteStatus ?: $oldSharee->inviteStatus,
1418                        $calendarId,
1419                        $sharee->href
1420                    ]);
1421                    continue 2;
1422                }
1423
1424            }
1425            // If we got here, it means it was a new sharee
1426            $insertStmt->execute([
1427                $calendarId,
1428                $sharee->principal,
1429                $sharee->access,
1430                \Sabre\DAV\UUIDUtil::getUUID(),
1431                $sharee->href,
1432                isset($sharee->properties['{DAV:}displayname']) ? $sharee->properties['{DAV:}displayname'] : null,
1433                $sharee->inviteStatus ?: \Sabre\DAV\Sharing\Plugin::INVITE_NORESPONSE,
1434                $instanceId
1435            ]);
1436
1437        }
1438
1439    }
1440
1441    /**
1442     * Returns the list of people whom a calendar is shared with.
1443     *
1444     * Every item in the returned list must be a Sharee object with at
1445     * least the following properties set:
1446     *   $href
1447     *   $shareAccess
1448     *   $inviteStatus
1449     *
1450     * and optionally:
1451     *   $properties
1452     *
1453     * @param mixed $calendarId
1454     * @return \Sabre\DAV\Xml\Element\Sharee[]
1455     */
1456    function getInvites($calendarId) {
1457
1458        if (!is_array($calendarId)) {
1459            throw new \InvalidArgumentException('The value passed to getInvites() is expected to be an array with a calendarId and an instanceId');
1460        }
1461        list($calendarId, $instanceId) = $calendarId;
1462
1463        $query = <<<SQL
1464SELECT
1465    principaluri,
1466    access,
1467    share_href,
1468    share_displayname,
1469    share_invitestatus
1470FROM {$this->calendarInstancesTableName}
1471WHERE
1472    calendarid = ?
1473SQL;
1474
1475        $stmt = $this->pdo->prepare($query);
1476        $stmt->execute([$calendarId]);
1477
1478        $result = [];
1479        while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
1480
1481            $result[] = new Sharee([
1482                'href'   => isset($row['share_href']) ? $row['share_href'] : \Sabre\HTTP\encodePath($row['principaluri']),
1483                'access' => (int)$row['access'],
1484                /// Everyone is always immediately accepted, for now.
1485                'inviteStatus' => (int)$row['share_invitestatus'],
1486                'properties'   =>
1487                    !empty($row['share_displayname'])
1488                    ? ['{DAV:}displayname' => $row['share_displayname']]
1489                    : [],
1490                'principal' => $row['principaluri'],
1491            ]);
1492
1493        }
1494        return $result;
1495
1496    }
1497
1498    /**
1499     * Publishes a calendar
1500     *
1501     * @param mixed $calendarId
1502     * @param bool $value
1503     * @return void
1504     */
1505    function setPublishStatus($calendarId, $value) {
1506
1507        throw new DAV\Exception\NotImplemented('Not implemented');
1508
1509    }
1510
1511}
1512