1<?php
2/**
3  * Helper Class for the DAVCal plugin
4  * This helper does the actual work.
5  *
6  */
7
8// must be run within Dokuwiki
9if(!defined('DOKU_INC')) die();
10
11class helper_plugin_davcal extends DokuWiki_Plugin {
12
13  protected $sqlite = null;
14  protected $cachedValues = array();
15
16  /**
17    * Constructor to load the configuration and the SQLite plugin
18    */
19  public function __construct() {
20  }
21
22  /** Establish and initialize the database if not already done
23   * @return sqlite interface or false
24   */
25  private function getDB()
26  {
27      if($this->sqlite === null)
28      {
29        $this->sqlite = new \dokuwiki\plugin\sqlite\SQLiteDB('davcal', DOKU_PLUGIN.'davcal/db/');
30        if(!$this->sqlite)
31        {
32            \dokuwiki\Logger::error('DAVCAL', 'This plugin requires the sqlite plugin. Please install it.', __FILE__, __LINE__);
33            msg('This plugin requires the sqlite plugin. Please install it.', -1);
34            return false;
35        }
36      }
37      return $this->sqlite;
38  }
39
40  /**
41   * Retrieve meta data for a given page
42   *
43   * @param string $id optional The page ID
44   * @return array The metadata
45   */
46  private function getMeta($id = null) {
47    global $ID;
48    global $INFO;
49
50    if ($id === null) $id = $ID;
51
52    if($ID === $id && $INFO['meta']) {
53        $meta = $INFO['meta'];
54    } else {
55        $meta = p_get_metadata($id);
56    }
57
58    return $meta;
59  }
60
61  /**
62   * Retrieve the meta data for a given page
63   *
64   * @param string $id optional The page ID
65   * @return array with meta data
66   */
67  public function getCalendarMetaForPage($id = null)
68  {
69      if(is_null($id))
70      {
71          global $ID;
72          $id = $ID;
73      }
74
75      $meta = $this->getMeta($id);
76      if(isset($meta['plugin_davcal']))
77        return $meta['plugin_davcal'];
78      else
79        return array();
80  }
81
82  /**
83   * Check the permission of a user for a given calendar ID
84   *
85   * @param string $id The calendar ID to check
86   * @return int AUTH_* constants
87   */
88  public function checkCalendarPermission($id)
89  {
90      if(strpos($id, 'webdav://') === 0)
91      {
92          $wdc =& plugin_load('helper', 'webdavclient');
93          if(is_null($wdc))
94            return AUTH_NONE;
95          $connectionId = str_replace('webdav://', '', $id);
96          $settings = $wdc->getConnection($connectionId);
97          if($settings === false)
98            return AUTH_NONE;
99          // Check if webdavclient has permissions attached to a page
100          if(!empty($settings['permission']))
101          {
102            $perm = auth_quickaclcheck($settings['permission']);
103            // In case the page has more than read permission, but the
104            // calendar is read-only, we need to modify the permission here.
105            if($perm > AUTH_READ && $settings['write'] == 0)
106              $perm = AUTH_READ;
107            return $perm;
108          }
109          if($settings['write'] === '1')
110            return AUTH_CREATE;
111          return AUTH_READ;
112      }
113      else
114      {
115          $calid = $this->getCalendarIdForPage($id);
116          // We return AUTH_READ if the calendar does not exist. This makes
117          // davcal happy when there are just included calendars
118          if($calid === false)
119            return AUTH_READ;
120          return auth_quickaclcheck($id);
121      }
122  }
123
124  /**
125   * Filter calendar pages and return only those where the current
126   * user has at least read permission.
127   *
128   * @param array $calendarPages Array with calendar pages to check
129   * @return array with filtered calendar pages
130   */
131  public function filterCalendarPagesByUserPermission($calendarPages)
132  {
133      $retList = array();
134      foreach($calendarPages as $page => $data)
135      {
136          if($this->checkCalendarPermission($page) >= AUTH_READ)
137          {
138              $retList[$page] = $data;
139          }
140      }
141      return $retList;
142  }
143
144  /**
145   * Get all calendar pages used by a given page
146   * based on the stored metadata
147   *
148   * @param string $id optional The page id
149   * @param bool $skipPermissionCheck optional Skip permission filtering (for private URLs)
150   * @return mixed The pages as array or false
151   */
152  public function getCalendarPagesByMeta($id = null, $skipPermissionCheck = false)
153  {
154      if(is_null($id))
155      {
156          global $ID;
157          $id = $ID;
158      }
159
160      $meta = $this->getCalendarMetaForPage($id);
161
162      if(isset($meta['id']))
163      {
164          if($skipPermissionCheck)
165          {
166              // For private URLs, return all pages without permission filtering
167              return $meta['id'];
168          }
169          else
170          {
171              // Filter the list of pages by permission
172              $pages = $this->filterCalendarPagesByUserPermission($meta['id']);
173              if(empty($pages))
174                return false;
175              return $pages;
176          }
177      }
178      return false;
179  }
180
181  /**
182   * Get a list of calendar names/pages/ids/colors
183   * for an array of page ids
184   *
185   * @param array $calendarPages The calendar pages to retrieve
186   * @return array The list
187   */
188  public function getCalendarMapForIDs($calendarPages)
189  {
190      $data = array();
191      foreach($calendarPages as $page => $color)
192      {
193            if(strpos($page, 'webdav://') === 0)
194            {
195                $wdc =& plugin_load('helper', 'webdavclient');
196                if(is_null($wdc))
197                    continue;
198                $connectionId = str_replace('webdav://', '', $page);
199                $settings = $wdc->getConnection($connectionId);
200                if($settings === false)
201                    continue;
202                $name = $settings['displayname'];
203                $write = ($settings['write'] === '1');
204                $calid = $connectionId;
205                $color = '#3a87ad';
206            }
207            else
208            {
209                $calid = $this->getCalendarIdForPage($page);
210                if($calid !== false)
211                {
212                    $settings = $this->getCalendarSettings($calid);
213                    $name = $settings['displayname'];
214                    $color = $settings['calendarcolor'];
215                    $write = (auth_quickaclcheck($page) > AUTH_READ);
216                }
217                else
218                {
219                    continue;
220                }
221            }
222            $data[] = array('name' => $name, 'page' => $page, 'calid' => $calid,
223                            'color' => $color, 'write' => $write);
224      }
225      return $data;
226  }
227
228  /**
229   * Get the saved calendar color for a given page.
230   *
231   * @param string $id optional The page ID
232   * @return mixed The color on success, otherwise false
233   */
234  public function getCalendarColorForPage($id = null)
235  {
236      if(is_null($id))
237      {
238          global $ID;
239          $id = $ID;
240      }
241
242      $calid = $this->getCalendarIdForPage($id);
243      if($calid === false)
244        return false;
245
246      return $this->getCalendarColorForCalendar($calid);
247  }
248
249  /**
250   * Get the saved calendar color for a given calendar ID.
251   *
252   * @param string $id optional The calendar ID
253   * @return mixed The color on success, otherwise false
254   */
255  public function getCalendarColorForCalendar($calid)
256  {
257      if(isset($this->cachedValues['calendarcolor'][$calid]))
258        return $this->cachedValues['calendarcolor'][$calid];
259
260      $row = $this->getCalendarSettings($calid);
261
262      if(!isset($row['calendarcolor']))
263        return false;
264
265      $color = $row['calendarcolor'];
266      $this->cachedValues['calendarcolor'][$calid] = $color;
267      return $color;
268  }
269
270  /**
271   * Get the user's principal URL for iOS sync
272   * @param string $user the user name
273   * @return the URL to the principal sync
274   */
275  public function getPrincipalUrlForUser($user)
276  {
277      if(is_null($user))
278        return false;
279      $url = DOKU_URL.'lib/plugins/davcal/calendarserver.php/principals/'.$user;
280      return $url;
281  }
282
283  /**
284   * Set the calendar color for a given page.
285   *
286   * @param string $color The color definition
287   * @param string $id optional The page ID
288   * @return boolean True on success, otherwise false
289   */
290  public function setCalendarColorForPage($color, $id = null)
291  {
292      if(is_null($id))
293      {
294          global $ID;
295          $id = $ID;
296      }
297      $calid = $this->getCalendarIdForPage($id);
298      if($calid === false)
299        return false;
300
301      $sqlite = $this->getDB();
302      if(!$sqlite)
303        return false;
304      $query = "UPDATE calendars SET calendarcolor = ? ".
305               " WHERE id = ?";
306      $res = $sqlite->query($query, $color, $calid);
307      if($res !== false)
308      {
309        $this->cachedValues['calendarcolor'][$calid] = $color;
310        return true;
311      }
312      return false;
313  }
314
315  /**
316   * Set the calendar name and description for a given page with a given
317   * page id.
318   * If the calendar doesn't exist, the calendar is created!
319   *
320   * @param string  $name The name of the new calendar
321   * @param string  $description The description of the new calendar
322   * @param string  $id (optional) The ID of the page
323   * @param string  $userid The userid of the creating user
324   *
325   * @return boolean True on success, otherwise false.
326   */
327  public function setCalendarNameForPage($name, $description, $id = null, $userid = null)
328  {
329      if(is_null($id))
330      {
331          global $ID;
332          $id = $ID;
333      }
334      if(is_null($userid))
335      {
336        if(isset($_SERVER['REMOTE_USER']) && !is_null($_SERVER['REMOTE_USER']))
337        {
338          $userid = $_SERVER['REMOTE_USER'];
339        }
340        else
341        {
342          $userid = uniqid('davcal-');
343        }
344      }
345      $calid = $this->getCalendarIdForPage($id);
346      if($calid === false)
347        return $this->createCalendarForPage($name, $description, $id, $userid);
348
349      $sqlite = $this->getDB();
350      if(!$sqlite)
351        return false;
352      $query = "UPDATE calendars SET displayname = ?, description = ? WHERE id = ?";
353      $res = $sqlite->query($query, $name, $description, $calid);
354      if($res !== false)
355        return true;
356      return false;
357  }
358
359  /**
360   * Update a calendar's displayname
361   *
362   * @param int $calid The calendar's ID
363   * @param string $name The new calendar name
364   *
365   * @return boolean True on success, otherwise false
366   */
367  public function updateCalendarName($calid, $name)
368  {
369      $sqlite = $this->getDB();
370      if(!$sqlite)
371        return false;
372      $query = "UPDATE calendars SET displayname = ? WHERE id = ?";
373      $res = $sqlite->query($query, $calid, $name);
374      if($res !== false)
375      {
376        $this->updateSyncTokenLog($calid, '', 'modified');
377        return true;
378      }
379      return false;
380  }
381
382  /**
383   * Update the calendar description
384   *
385   * @param int $calid The calendar's ID
386   * @param string $description The new calendar's description
387   *
388   * @return boolean True on success, otherwise false
389   */
390  public function updateCalendarDescription($calid, $description)
391  {
392      $sqlite = $this->getDB();
393      if(!$sqlite)
394        return false;
395      $query = "UPDATE calendars SET description = ? WHERE id = ?";
396      $res = $sqlite->query($query, $calid, $description);
397      if($res !== false)
398      {
399        $this->updateSyncTokenLog($calid, '', 'modified');
400        return true;
401      }
402      return false;
403  }
404
405  /**
406   * Update a calendar's timezone information
407   *
408   * @param int $calid The calendar's ID
409   * @param string $timezone The new timezone to set
410   *
411   * @return boolean True on success, otherwise false
412   */
413  public function updateCalendarTimezone($calid, $timezone)
414  {
415      $sqlite = $this->getDB();
416      if(!$sqlite)
417        return false;
418      $query = "UPDATE calendars SET timezone = ? WHERE id = ?";
419      $res = $sqlite->query($query, $calid, $timezone);
420      if($res !== false)
421      {
422        $this->updateSyncTokenLog($calid, '', 'modified');
423        return true;
424      }
425      return false;
426  }
427
428  /**
429   * Save the personal settings to the SQLite database 'calendarsettings'.
430   *
431   * @param array  $settings The settings array to store
432   * @param string $userid (optional) The userid to store
433   *
434   * @param boolean True on success, otherwise false
435   */
436  public function savePersonalSettings($settings, $userid = null)
437  {
438      if(is_null($userid))
439      {
440          if(isset($_SERVER['REMOTE_USER']) && !is_null($_SERVER['REMOTE_USER']))
441          {
442            $userid = $_SERVER['REMOTE_USER'];
443          }
444          else
445          {
446              return false;
447          }
448      }
449      $sqlite = $this->getDB();
450      if(!$sqlite)
451        return false;
452      $sqlite->query("BEGIN TRANSACTION");
453
454      $query = "DELETE FROM calendarsettings WHERE userid = ?";
455      $sqlite->query($query, $userid);
456
457      foreach($settings as $key => $value)
458      {
459          $query = "INSERT INTO calendarsettings (userid, key, value) VALUES (?, ?, ?)";
460          $res = $sqlite->query($query, $userid, $key, $value);
461          if($res === false)
462              return false;
463      }
464      $sqlite->query("COMMIT TRANSACTION");
465      $this->cachedValues['settings'][$userid] = $settings;
466      return true;
467  }
468
469  /**
470   * Retrieve the settings array for a given user id.
471   * Some sane defaults are returned, currently:
472   *
473   *    timezone    => local
474   *    weeknumbers => 0
475   *    workweek    => 0
476   *
477   * @param string $userid (optional) The user id to retrieve
478   *
479   * @return array The settings array
480   */
481  public function getPersonalSettings($userid = null)
482  {
483      $settings = array(
484        'timezone' => $this->getConf('timezone'),
485        'weeknumbers' => $this->getConf('weeknumbers'),
486        'workweek' => $this->getConf('workweek'),
487        'monday' => $this->getConf('monday'),
488        'timeformat' => $this->getConf('timeformat')
489      );
490      if(is_null($userid))
491      {
492          if(isset($_SERVER['REMOTE_USER']) && !is_null($_SERVER['REMOTE_USER']))
493          {
494            $userid = $_SERVER['REMOTE_USER'];
495          }
496          else
497          {
498            return $settings;
499          }
500      }
501
502      $sqlite = $this->getDB();
503      if(!$sqlite)
504        return false;
505      if(isset($this->cachedValues['settings'][$userid]))
506        return $this->cachedValues['settings'][$userid];
507      $query = "SELECT key, value FROM calendarsettings WHERE userid = ?";
508      $arr = $sqlite->queryAll($query, [$userid]);
509      foreach($arr as $row)
510      {
511          $settings[$row['key']] = $row['value'];
512      }
513      $this->cachedValues['settings'][$userid] = $settings;
514      return $settings;
515  }
516
517  /**
518   * Retrieve the calendar ID based on a page ID from the SQLite table
519   * 'pagetocalendarmapping'.
520   *
521   * @param string $id (optional) The page ID to retrieve the corresponding calendar
522   *
523   * @return mixed the ID on success, otherwise false
524   */
525  public function getCalendarIdForPage($id = null)
526  {
527      if(is_null($id))
528      {
529          global $ID;
530          $id = $ID;
531      }
532
533      if(isset($this->cachedValues['calid'][$id]))
534        return $this->cachedValues['calid'][$id];
535
536      $sqlite = $this->getDB();
537      if(!$sqlite)
538        return false;
539      $query = "SELECT calid FROM pagetocalendarmapping WHERE page = ?";
540      $row = $sqlite->queryRecord($query, [$id]);
541      if(isset($row['calid']))
542      {
543        $calid = $row['calid'];
544        $this->cachedValues['calid'] = $calid;
545        return $calid;
546      }
547      return false;
548  }
549
550  /**
551   * Retrieve the complete calendar id to page mapping.
552   * This is necessary to be able to retrieve a list of
553   * calendars for a given user and check the access rights.
554   *
555   * @return array The mapping array
556   */
557  public function getCalendarIdToPageMapping()
558  {
559      $sqlite = $this->getDB();
560      if(!$sqlite)
561        return false;
562      $query = "SELECT calid, page FROM pagetocalendarmapping";
563      $arr = $sqlite->queryAll($query, []);
564      return $arr;
565  }
566
567  /**
568   * Retrieve all calendar IDs a given user has access to.
569   * The user is specified by the principalUri, so the
570   * user name is actually split from the URI component.
571   *
572   * Access rights are checked against DokuWiki's ACL
573   * and applied accordingly.
574   *
575   * @param string $principalUri The principal URI to work on
576   *
577   * @return array An associative array of calendar IDs
578   */
579  public function getCalendarIdsForUser($principalUri)
580  {
581      global $auth;
582      $user = explode('/', $principalUri);
583      $user = end($user);
584      $mapping = $this->getCalendarIdToPageMapping();
585      $calids = array();
586      $ud = $auth->getUserData($user);
587      $groups = $ud['grps'];
588      foreach($mapping as $row)
589      {
590          $id = $row['calid'];
591          $enabled = $this->getCalendarStatus($id);
592          if($enabled == false)
593            continue;
594          $page = $row['page'];
595          $acl = auth_aclcheck($page, $user, $groups);
596          if($acl >= AUTH_READ)
597          {
598              $write = $acl > AUTH_READ;
599              $calids[$id] = array('readonly' => !$write);
600          }
601      }
602      return $calids;
603  }
604
605  /**
606   * Create a new calendar for a given page ID and set name and description
607   * accordingly. Also update the pagetocalendarmapping table on success.
608   *
609   * @param string $name The calendar's name
610   * @param string $description The calendar's description
611   * @param string $id (optional) The page ID to work on
612   * @param string $userid (optional) The user ID that created the calendar
613   *
614   * @return boolean True on success, otherwise false
615   */
616  public function createCalendarForPage($name, $description, $id = null, $userid = null)
617  {
618      if(is_null($id))
619      {
620          global $ID;
621          $id = $ID;
622      }
623      if(is_null($userid))
624      {
625        if(isset($_SERVER['REMOTE_USER']) && !is_null($_SERVER['REMOTE_USER']))
626        {
627          $userid = $_SERVER['REMOTE_USER'];
628        }
629        else
630        {
631          $userid = uniqid('davcal-');
632        }
633      }
634      $values = array('principals/'.$userid,
635                      $name,
636                      str_replace(array('/', ' ', ':'), '_', $id),
637                      $description,
638                      'VEVENT,VTODO',
639                      0,
640                      1);
641
642      $sqlite = $this->getDB();
643      if(!$sqlite)
644        return false;
645      $query = "INSERT INTO calendars (principaluri, displayname, uri, description, components, transparent, synctoken) ".
646               "VALUES (?, ?, ?, ?, ?, ?, ?)";
647      $sqlite->query($query, $values);
648
649      $query = "SELECT id FROM calendars WHERE principaluri = ? AND displayname = ? AND ".
650               "uri = ? AND description = ?";
651      $row = $sqlite->queryRecord($query, array($values[0], $values[1], $values[2], $values[3]));
652
653      if(isset($row['id']))
654      {
655          $query = "INSERT INTO pagetocalendarmapping (page, calid) VALUES (?, ?)";
656          $sqlite->query($query, array($id, $row['id']));
657          return true;
658      }
659
660      return false;
661  }
662
663  /**
664   * Add a new calendar entry to the given calendar. Calendar data is
665   * specified as ICS file, thus it needs to be parsed first.
666   *
667   * This is mainly needed for the sync support.
668   *
669   * @param int $calid The calendar's ID
670   * @param string $uri The new object URI
671   * @param string $ics The ICS file
672   *
673   * @return mixed The etag.
674   */
675  public function addCalendarEntryToCalendarByICS($calid, $uri, $ics)
676  {
677    $extraData = $this->getDenormalizedData($ics);
678
679    $sqlite = $this->getDB();
680    if(!$sqlite)
681      return false;
682    $query = "INSERT INTO calendarobjects (calendarid, uri, calendardata, lastmodified, etag, size, componenttype, firstoccurence, lastoccurence, uid) VALUES (?,?,?,?,?,?,?,?,?,?)";
683    $res = $sqlite->query($query,
684            $calid,
685            $uri,
686            $ics,
687            time(),
688            $extraData['etag'],
689            $extraData['size'],
690            $extraData['componentType'],
691            $extraData['firstOccurence'],
692            $extraData['lastOccurence'],
693            $extraData['uid']);
694            // If successfully, update the sync token database
695    if($res !== false)
696    {
697        $this->updateSyncTokenLog($calid, $uri, 'added');
698    }
699    return $extraData['etag'];
700  }
701
702  /**
703   * Edit a calendar entry by providing a new ICS file. This is mainly
704   * needed for the sync support.
705   *
706   * @param int $calid The calendar's IS
707   * @param string $uri The object's URI to modify
708   * @param string $ics The new object's ICS file
709   */
710  public function editCalendarEntryToCalendarByICS($calid, $uri, $ics)
711  {
712      $extraData = $this->getDenormalizedData($ics);
713
714      $sqlite = $this->getDB();
715      if(!$sqlite)
716        return false;
717      $query = "UPDATE calendarobjects SET calendardata = ?, lastmodified = ?, etag = ?, size = ?, componenttype = ?, firstoccurence = ?, lastoccurence = ?, uid = ? WHERE calendarid = ? AND uri = ?";
718      $res = $sqlite->query($query,
719        $ics,
720        time(),
721        $extraData['etag'],
722        $extraData['size'],
723        $extraData['componentType'],
724        $extraData['firstOccurence'],
725        $extraData['lastOccurence'],
726        $extraData['uid'],
727        $calid,
728        $uri
729      );
730      if($res !== false)
731      {
732          $this->updateSyncTokenLog($calid, $uri, 'modified');
733      }
734      return $extraData['etag'];
735  }
736
737  /**
738   * Add a new iCal entry for a given page, i.e. a given calendar.
739   *
740   * The parameter array needs to contain
741   *   detectedtz       => The timezone as detected by the browser
742   *   currenttz        => The timezone in use by the calendar
743   *   eventfrom        => The event's start date
744   *   eventfromtime    => The event's start time
745   *   eventto          => The event's end date
746   *   eventtotime      => The event's end time
747   *   eventname        => The event's name
748   *   eventdescription => The event's description
749   *
750   * @param string $id The page ID to work on
751   * @param string $user The user who created the calendar
752   * @param string $params A parameter array with values to create
753   *
754   * @return boolean True on success, otherwise false
755   */
756  public function addCalendarEntryToCalendarForPage($id, $user, $params)
757  {
758      if($params['currenttz'] !== '' && $params['currenttz'] !== 'local')
759          $timezone = new \DateTimeZone($params['currenttz']);
760      elseif($params['currenttz'] === 'local')
761          $timezone = new \DateTimeZone($params['detectedtz']);
762      else
763          $timezone = new \DateTimeZone('UTC');
764
765      // Retrieve dates from settings
766      $startDate = explode('-', $params['eventfrom']);
767      $startTime = explode(':', $params['eventfromtime']);
768      $endDate = explode('-', $params['eventto']);
769      $endTime = explode(':', $params['eventtotime']);
770
771      // Load SabreDAV
772      require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
773      $vcalendar = new \Sabre\VObject\Component\VCalendar();
774
775      // Add VCalendar, UID and Event Name
776      $event = $vcalendar->add('VEVENT');
777      $uuid = \Sabre\VObject\UUIDUtil::getUUID();
778      $event->add('UID', $uuid);
779      $event->summary = $params['eventname'];
780
781      // Add a description if requested
782      $description = $params['eventdescription'];
783      if($description !== '')
784        $event->add('DESCRIPTION', $description);
785
786      // Add a location if requested
787      $location = $params['eventlocation'];
788      if($location !== '')
789        $event->add('LOCATION', $location);
790
791      // Add attachments
792      $attachments = $params['attachments'];
793      if(!is_null($attachments))
794        foreach($attachments as $attachment)
795          $event->add('ATTACH', $attachment);
796
797      // Create a timestamp for last modified, created and dtstamp values in UTC
798      $dtStamp = new \DateTime(null, new \DateTimeZone('UTC'));
799      $event->add('DTSTAMP', $dtStamp);
800      $event->add('CREATED', $dtStamp);
801      $event->add('LAST-MODIFIED', $dtStamp);
802
803      // Adjust the start date, based on the given timezone information
804      $dtStart = new \DateTime();
805      $dtStart->setTimezone($timezone);
806      $dtStart->setDate(intval($startDate[0]), intval($startDate[1]), intval($startDate[2]));
807
808      // Only add the time values if it's not an allday event
809      if($params['allday'] != '1')
810        $dtStart->setTime(intval($startTime[0]), intval($startTime[1]), 0);
811
812      // Adjust the end date, based on the given timezone information
813      $dtEnd = new \DateTime();
814      $dtEnd->setTimezone($timezone);
815      $dtEnd->setDate(intval($endDate[0]), intval($endDate[1]), intval($endDate[2]));
816
817      // Only add the time values if it's not an allday event
818      if($params['allday'] != '1')
819        $dtEnd->setTime(intval($endTime[0]), intval($endTime[1]), 0);
820
821      // According to the VCal spec, we need to add a whole day here
822      if($params['allday'] == '1')
823          $dtEnd->add(new \DateInterval('P1D'));
824
825      // Really add Start and End events
826      $dtStartEv = $event->add('DTSTART', $dtStart);
827      $dtEndEv = $event->add('DTEND', $dtEnd);
828
829      // Adjust the DATE format for allday events
830      if($params['allday'] == '1')
831      {
832          $dtStartEv['VALUE'] = 'DATE';
833          $dtEndEv['VALUE'] = 'DATE';
834      }
835
836      $eventStr = $vcalendar->serialize();
837
838      if(strpos($id, 'webdav://') === 0)
839      {
840          $wdc =& plugin_load('helper', 'webdavclient');
841          if(is_null($wdc))
842            return false;
843          $connectionId = str_replace('webdav://', '', $id);
844          return $wdc->addCalendarEntry($connectionId, $eventStr);
845      }
846      else
847      {
848          // Actually add the values to the database
849          $calid = $this->getCalendarIdForPage($id);
850          $uri = $uri = 'dokuwiki-' . bin2hex(random_bytes(16)) . '.ics';
851          $now = new \DateTime();
852
853          $sqlite = $this->getDB();
854          if(!$sqlite)
855            return false;
856          $query = "INSERT INTO calendarobjects (calendarid, uri, calendardata, lastmodified, componenttype, firstoccurence, lastoccurence, size, etag, uid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
857          $res = $sqlite->query($query, $calid, $uri, $eventStr, $now->getTimestamp(), 'VEVENT',
858                                      $event->DTSTART->getDateTime()->getTimeStamp(), $event->DTEND->getDateTime()->getTimeStamp(),
859                                      strlen($eventStr), md5($eventStr), $uuid);
860
861          // If successfully, update the sync token database
862          if($res !== false)
863          {
864              $this->updateSyncTokenLog($calid, $uri, 'added');
865              return true;
866          }
867      }
868      return false;
869  }
870
871  /**
872   * Retrieve the calendar settings of a given calendar id
873   *
874   * @param string $calid The calendar ID
875   *
876   * @return array The calendar settings array
877   */
878  public function getCalendarSettings($calid)
879  {
880      $sqlite = $this->getDB();
881      if(!$sqlite)
882        return false;
883      $query = "SELECT id, principaluri, calendarcolor, displayname, uri, description, components, transparent, synctoken, disabled, timezone FROM calendars WHERE id= ? ";
884      $row = $sqlite->queryRecord($query, [$calid]);
885      return $row;
886  }
887
888  /**
889   * Retrieve the calendar status of a given calendar id
890   *
891   * @param string $calid The calendar ID
892   * @return boolean True if calendar is enabled, otherwise false
893   */
894  public function getCalendarStatus($calid)
895  {
896      $sqlite = $this->getDB();
897      if(!$sqlite)
898        return false;
899      $query = "SELECT disabled FROM calendars WHERE id = ?";
900      $row = $sqlite->queryRecord($query, [$calid]);
901      if($row && $row['disabled'] == 1)
902        return false;
903      else
904        return true;
905  }
906
907  /**
908   * Disable a calendar for a given page
909   *
910   * @param string $id The page ID
911   *
912   * @return boolean true on success, otherwise false
913   */
914  public function disableCalendarForPage($id)
915  {
916      $calid = $this->getCalendarIdForPage($id);
917      if($calid === false)
918        return false;
919
920      $sqlite = $this->getDB();
921      if(!$sqlite)
922        return false;
923      $query = "UPDATE calendars SET disabled = 1 WHERE id = ?";
924      $res = $sqlite->query($query, $calid);
925      if($res !== false)
926        return true;
927      return false;
928  }
929
930  /**
931   * Enable a calendar for a given page
932   *
933   * @param string $id The page ID
934   *
935   * @return boolean true on success, otherwise false
936   */
937  public function enableCalendarForPage($id)
938  {
939      $calid = $this->getCalendarIdForPage($id);
940      if($calid === false)
941        return false;
942      $sqlite = $this->getDB();
943      if(!$sqlite)
944        return false;
945      $query = "UPDATE calendars SET disabled = 0 WHERE id = ?";
946      $res = $sqlite->query($query, $calid);
947      if($res !== false)
948        return true;
949      return false;
950  }
951
952  /**
953   * Retrieve all events that are within a given date range,
954   * based on the timezone setting.
955   *
956   * There is also support for retrieving recurring events,
957   * using Sabre's VObject Iterator. Recurring events are represented
958   * as individual calendar entries with the same UID.
959   *
960   * @param string $id The page ID to work with
961   * @param string $user The user ID to work with
962   * @param string $startDate The start date as a string
963   * @param string $endDate The end date as a string
964   * @param string $color (optional) The calendar's color
965   *
966   * @return array An array containing the calendar entries.
967   */
968  public function getEventsWithinDateRange($id, $user, $startDate, $endDate, $timezone, $color = null)
969  {
970      if($timezone !== '' && $timezone !== 'local')
971          $timezone = new \DateTimeZone($timezone);
972      else
973          $timezone = new \DateTimeZone('UTC');
974      $data = array();
975      $calname = 'unknown';
976
977      $sqlite = $this->getDB();
978      if(!$sqlite)
979        return false;
980
981      $calid = $this->getCalendarIdForPage($id);
982      $query = "SELECT calendardata, componenttype, uid FROM calendarobjects WHERE calendarid = ?";
983      $params = array($calid);
984      $startTs = null;
985      $endTs = null;
986      if($startDate !== null)
987      {
988        $startTs = new \DateTime($startDate);
989        $query .= " AND lastoccurence > ?";
990        $params[] = $startTs->getTimestamp();
991      }
992      if($endDate !== null)
993      {
994        $endTs = new \DateTime($endDate);
995        $query .= " AND firstoccurence < ?";
996        $params[] = $endTs->getTimestamp();
997      }
998
999      require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
1000
1001      if(strpos($id, 'webdav://') === 0)
1002      {
1003          $wdc =& plugin_load('helper', 'webdavclient');
1004          if(is_null($wdc))
1005            return $data;
1006          $connectionId = str_replace('webdav://', '', $id);
1007          $arr = $wdc->getCalendarEntries($connectionId, $startDate, $endDate);
1008          $conn = $wdc->getConnection($connectionId);
1009          $calname = $conn['displayname'];
1010      }
1011      else
1012      {
1013          if(is_null($color))
1014            $color = $this->getCalendarColorForCalendar($calid);
1015
1016          $enabled = $this->getCalendarStatus($calid);
1017          if($enabled === false)
1018            return $data;
1019
1020          $settings = $this->getCalendarSettings($calid);
1021          $calname = $settings['displayname'];
1022
1023          $arr = $sqlite->queryAll($query, $params);
1024      }
1025
1026      foreach($arr as $row)
1027      {
1028          if(isset($row['calendardata']))
1029          {
1030              $entry = array();
1031              $vcal = \Sabre\VObject\Reader::read($row['calendardata']);
1032              $recurrence = $vcal->VEVENT->RRULE;
1033              if($recurrence != null)
1034              {
1035                  $rEvents = new \Sabre\VObject\Recur\EventIterator(array($vcal->VEVENT));
1036                  $rEvents->rewind();
1037                  while($rEvents->valid())
1038                  {
1039                      $event = $rEvents->getEventObject();
1040                      if(($endTs !== null) && ($rEvents->getDtStart()->getTimestamp() > $endTs->getTimestamp()))
1041                          break;
1042                      if(($startTs != null) && ($rEvents->getDtEnd()->getTimestamp() < $startTs->getTimestamp()))
1043                      {
1044                          $rEvents->next();
1045                          continue;
1046                      }
1047                      $data[] = array_merge(array('calendarname' => $calname),
1048                                            $this->convertIcalDataToEntry($event, $id, $timezone, $row['uid'], $color, true));
1049                      $rEvents->next();
1050                  }
1051              }
1052              else
1053                $data[] = array_merge(array('calendarname' => $calname),
1054                                      $this->convertIcalDataToEntry($vcal->VEVENT, $id, $timezone, $row['uid'], $color));
1055          }
1056      }
1057      return $data;
1058  }
1059
1060  /**
1061   * Helper function that parses the iCal data of a VEVENT to a calendar entry.
1062   *
1063   * @param \Sabre\VObject\VEvent $event The event to parse
1064   * @param \DateTimeZone $timezone The timezone object
1065   * @param string $uid The entry's UID
1066   * @param boolean $recurring (optional) Set to true to define a recurring event
1067   *
1068   * @return array The parse calendar entry
1069   */
1070  private function convertIcalDataToEntry($event, $page, $timezone, $uid, $color, $recurring = false)
1071  {
1072      $entry = array();
1073      $start = $event->DTSTART;
1074      // Parse only if the start date/time is present
1075      if($start !== null)
1076      {
1077        $dtStart = $start->getDateTime();
1078        $dtStart->setTimezone($timezone);
1079
1080        // moment.js doesn't like times be given even if
1081        // allDay is set to true
1082        // This should fix T23
1083        if($start['VALUE'] == 'DATE')
1084        {
1085          $entry['allDay'] = true;
1086          $entry['start'] = $dtStart->format("Y-m-d");
1087        }
1088        else
1089        {
1090          $entry['allDay'] = false;
1091          $entry['start'] = $dtStart->format(\DateTime::ATOM);
1092        }
1093      }
1094      $end = $event->DTEND;
1095      // Parse only if the end date/time is present
1096      if($end !== null)
1097      {
1098        $dtEnd = $end->getDateTime();
1099        $dtEnd->setTimezone($timezone);
1100        if($end['VALUE'] == 'DATE')
1101          $entry['end'] = $dtEnd->format("Y-m-d");
1102        else
1103          $entry['end'] = $dtEnd->format(\DateTime::ATOM);
1104      }
1105      $duration = $event->DURATION;
1106      // Parse duration only if start is set, but end is missing
1107      if($start !== null && $end == null && $duration !== null)
1108      {
1109          $interval = $duration->getDateInterval();
1110          $dtStart = $start->getDateTime();
1111          $dtStart->setTimezone($timezone);
1112          $dtEnd = $dtStart->add($interval);
1113          $dtEnd->setTimezone($timezone);
1114          $entry['end'] = $dtEnd->format(\DateTime::ATOM);
1115      }
1116      $description = $event->DESCRIPTION;
1117      if($description !== null)
1118        $entry['description'] = (string)$description;
1119      else
1120        $entry['description'] = '';
1121      $attachments = $event->ATTACH;
1122      if($attachments !== null)
1123      {
1124        $entry['attachments'] = array();
1125        foreach($attachments as $attachment)
1126          $entry['attachments'][] = (string)$attachment;
1127      }
1128      $entry['title'] = (string)$event->summary;
1129      $entry['location'] = (string)$event->location;
1130      $entry['id'] = $uid;
1131      $entry['page'] = $page;
1132      $entry['color'] = $color;
1133      $entry['recurring'] = $recurring;
1134
1135      return $entry;
1136  }
1137
1138  /**
1139   * Retrieve an event by its UID
1140   *
1141   * @param string $uid The event's UID
1142   *
1143   * @return mixed The table row with the given event
1144   */
1145  public function getEventWithUid($uid)
1146  {
1147      $sqlite = $this->getDB();
1148      if(!$sqlite)
1149        return false;
1150      $query = "SELECT calendardata, calendarid, componenttype, uri FROM calendarobjects WHERE uid = ?";
1151      $row = $sqlite->queryRecord($query, [$uid]);
1152      return $row;
1153  }
1154
1155  /**
1156   * Retrieve information of a calendar's object, not including the actual
1157   * calendar data! This is mainly needed for the sync support.
1158   *
1159   * @param int $calid The calendar ID
1160   *
1161   * @return mixed The result
1162   */
1163  public function getCalendarObjects($calid)
1164  {
1165      $sqlite = $this->getDB();
1166      if(!$sqlite)
1167        return false;
1168      $query = "SELECT id, uri, lastmodified, etag, calendarid, size, componenttype FROM calendarobjects WHERE calendarid = ?";
1169      $arr = $sqlite->queryAll($query, [$calid]);
1170      return $arr;
1171  }
1172
1173  /**
1174   * Retrieve a single calendar object by calendar ID and URI
1175   *
1176   * @param int $calid The calendar's ID
1177   * @param string $uri The object's URI
1178   *
1179   * @return mixed The result
1180   */
1181  public function getCalendarObjectByUri($calid, $uri)
1182  {
1183      $sqlite = $this->getDB();
1184      if(!$sqlite)
1185        return false;
1186      $query = "SELECT id, uri, lastmodified, etag, calendarid, size, calendardata, componenttype FROM calendarobjects WHERE calendarid = ? AND uri = ?";
1187      $row = $sqlite->queryRecord($query, [$calid, $uri]);
1188      return $row;
1189  }
1190
1191  /**
1192   * Retrieve several calendar objects by specifying an array of URIs.
1193   * This is mainly neede for sync.
1194   *
1195   * @param int $calid The calendar's ID
1196   * @param array $uris An array of URIs
1197   *
1198   * @return mixed The result
1199   */
1200  public function getMultipleCalendarObjectsByUri($calid, $uris)
1201  {
1202      $sqlite = $this->getDB();
1203      if(!$sqlite)
1204        return false;
1205      $query = "SELECT id, uri, lastmodified, etag, calendarid, size, calendardata, componenttype FROM calendarobjects WHERE calendarid = ? AND uri IN (";
1206      $query .= implode(',', array_fill(0, count($uris), '?'));
1207      $query .= ')';
1208      $vals = array_merge(array($calid), $uris);
1209      $arr = $sqlite->queryAll($query, $vals);
1210      return $arr;
1211  }
1212
1213  /**
1214   * Retrieve all calendar events for a given calendar ID
1215   *
1216   * @param string $calid The calendar's ID
1217   *
1218   * @return array An array containing all calendar data
1219   */
1220  public function getAllCalendarEvents($calid)
1221  {
1222      $enabled = $this->getCalendarStatus($calid);
1223      if($enabled === false)
1224        return false;
1225
1226      $sqlite = $this->getDB();
1227      if(!$sqlite)
1228        return false;
1229      $query = "SELECT calendardata, uid, componenttype, uri FROM calendarobjects WHERE calendarid = ?";
1230      $arr = $sqlite->queryAll($query, [$calid]);
1231      return $arr;
1232  }
1233
1234  /**
1235   * Edit a calendar entry for a page, given by its parameters.
1236   * The params array has the same format as @see addCalendarEntryForPage
1237   *
1238   * @param string $id The page's ID to work on
1239   * @param string $user The user's ID to work on
1240   * @param array $params The parameter array for the edited calendar event
1241   *
1242   * @return boolean True on success, otherwise false
1243   */
1244  public function editCalendarEntryForPage($id, $user, $params)
1245  {
1246      if($params['currenttz'] !== '' && $params['currenttz'] !== 'local')
1247          $timezone = new \DateTimeZone($params['currenttz']);
1248      elseif($params['currenttz'] === 'local')
1249          $timezone = new \DateTimeZone($params['detectedtz']);
1250      else
1251          $timezone = new \DateTimeZone('UTC');
1252
1253      // Parse dates
1254      $startDate = explode('-', $params['eventfrom']);
1255      $startTime = explode(':', $params['eventfromtime']);
1256      $endDate = explode('-', $params['eventto']);
1257      $endTime = explode(':', $params['eventtotime']);
1258
1259      // Retrieve the existing event based on the UID
1260      $uid = $params['uid'];
1261
1262      if(strpos($id, 'webdav://') === 0)
1263      {
1264        $wdc =& plugin_load('helper', 'webdavclient');
1265        if(is_null($wdc))
1266          return false;
1267        $event = $wdc->getCalendarEntryByUid($uid);
1268      }
1269      else
1270      {
1271        $event = $this->getEventWithUid($uid);
1272      }
1273
1274      // Load SabreDAV
1275      require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
1276      if(!isset($event['calendardata']))
1277        return false;
1278      $uri = $event['uri'];
1279      $calid = $event['calendarid'];
1280
1281      // Parse the existing event
1282      $vcal = \Sabre\VObject\Reader::read($event['calendardata']);
1283      $vevent = $vcal->VEVENT;
1284
1285      // Set the new event values
1286      $vevent->summary = $params['eventname'];
1287      $dtStamp = new \DateTime(null, new \DateTimeZone('UTC'));
1288      $description = $params['eventdescription'];
1289      $location = $params['eventlocation'];
1290
1291      // Remove existing timestamps to overwrite them
1292      $vevent->remove('DESCRIPTION');
1293      $vevent->remove('DTSTAMP');
1294      $vevent->remove('LAST-MODIFIED');
1295      $vevent->remove('ATTACH');
1296      $vevent->remove('LOCATION');
1297
1298      // Add new time stamps, description and location
1299      $vevent->add('DTSTAMP', $dtStamp);
1300      $vevent->add('LAST-MODIFIED', $dtStamp);
1301      if($description !== '')
1302        $vevent->add('DESCRIPTION', $description);
1303      if($location !== '')
1304        $vevent->add('LOCATION', $location);
1305
1306      // Add attachments
1307      $attachments = $params['attachments'];
1308      if(!is_null($attachments))
1309        foreach($attachments as $attachment)
1310          $vevent->add('ATTACH', $attachment);
1311
1312      // Setup DTSTART
1313      $dtStart = new \DateTime();
1314      $dtStart->setTimezone($timezone);
1315      $dtStart->setDate(intval($startDate[0]), intval($startDate[1]), intval($startDate[2]));
1316      if($params['allday'] != '1')
1317        $dtStart->setTime(intval($startTime[0]), intval($startTime[1]), 0);
1318
1319      // Setup DTEND
1320      $dtEnd = new \DateTime();
1321      $dtEnd->setTimezone($timezone);
1322      $dtEnd->setDate(intval($endDate[0]), intval($endDate[1]), intval($endDate[2]));
1323      if($params['allday'] != '1')
1324        $dtEnd->setTime(intval($endTime[0]), intval($endTime[1]), 0);
1325
1326      // According to the VCal spec, we need to add a whole day here
1327      if($params['allday'] == '1')
1328          $dtEnd->add(new \DateInterval('P1D'));
1329      $vevent->remove('DTSTART');
1330      $vevent->remove('DTEND');
1331      $dtStartEv = $vevent->add('DTSTART', $dtStart);
1332      $dtEndEv = $vevent->add('DTEND', $dtEnd);
1333
1334      // Remove the time for allday events
1335      if($params['allday'] == '1')
1336      {
1337          $dtStartEv['VALUE'] = 'DATE';
1338          $dtEndEv['VALUE'] = 'DATE';
1339      }
1340      $eventStr = $vcal->serialize();
1341      if(strpos($id, 'webdav://') === 0)
1342      {
1343          $connectionId = str_replace('webdav://', '', $id);
1344          return $wdc->editCalendarEntry($connectionId, $uid, $eventStr);
1345      }
1346      else
1347      {
1348          $sqlite = $this->getDB();
1349          if(!$sqlite)
1350            return false;
1351          $now = new DateTime();
1352          // Actually write to the database
1353          $query = "UPDATE calendarobjects SET calendardata = ?, lastmodified = ?, ".
1354                   "firstoccurence = ?, lastoccurence = ?, size = ?, etag = ? WHERE uid = ?";
1355          $res = $sqlite->query($query, $eventStr, $now->getTimestamp(), $dtStart->getTimestamp(),
1356                                      $dtEnd->getTimestamp(), strlen($eventStr), md5($eventStr), $uid);
1357          if($res !== false)
1358          {
1359              $this->updateSyncTokenLog($calid, $uri, 'modified');
1360              return true;
1361          }
1362      }
1363      return false;
1364  }
1365
1366  /**
1367   * Delete an event from a calendar by calendar ID and URI
1368   *
1369   * @param int $calid The calendar's ID
1370   * @param string $uri The object's URI
1371   *
1372   * @return true
1373   */
1374  public function deleteCalendarEntryForCalendarByUri($calid, $uri)
1375  {
1376      $sqlite = $this->getDB();
1377      if(!$sqlite)
1378        return false;
1379      $query = "DELETE FROM calendarobjects WHERE calendarid = ? AND uri = ?";
1380      $res = $sqlite->query($query, $calid, $uri);
1381      if($res !== false)
1382      {
1383          $this->updateSyncTokenLog($calid, $uri, 'deleted');
1384      }
1385      return true;
1386  }
1387
1388  /**
1389   * Delete a calendar entry for a given page. Actually, the event is removed
1390   * based on the entry's UID, so that page ID is no used.
1391   *
1392   * @param string $id The page's ID (unused)
1393   * @param array $params The parameter array to work with
1394   *
1395   * @return boolean True
1396   */
1397  public function deleteCalendarEntryForPage($id, $params)
1398  {
1399      $uid = $params['uid'];
1400      if(strpos($id, 'webdav://') === 0)
1401      {
1402        $wdc =& plugin_load('helper', 'webdavclient');
1403        if(is_null($wdc))
1404          return false;
1405        $connectionId = str_replace('webdav://', '', $id);
1406        $result = $wdc->deleteCalendarEntry($connectionId, $uid);
1407        return $result;
1408      }
1409      $sqlite = $this->getDB();
1410      if(!$sqlite)
1411        return false;
1412      $event = $this->getEventWithUid($uid);
1413      $calid = $event['calendarid'];
1414      $uri = $event['uri'];
1415      $query = "DELETE FROM calendarobjects WHERE uid = ?";
1416      $res = $sqlite->query($query, $uid);
1417      if($res !== false)
1418      {
1419          $this->updateSyncTokenLog($calid, $uri, 'deleted');
1420      }
1421      return true;
1422  }
1423
1424  /**
1425   * Retrieve the current sync token for a calendar
1426   *
1427   * @param string $calid The calendar id
1428   *
1429   * @return mixed The synctoken or false
1430   */
1431  public function getSyncTokenForCalendar($calid)
1432  {
1433      $row = $this->getCalendarSettings($calid);
1434      if(isset($row['synctoken']))
1435          return $row['synctoken'];
1436      return false;
1437  }
1438
1439  /**
1440   * Helper function to convert the operation name to
1441   * an operation code as stored in the database
1442   *
1443   * @param string $operationName The operation name
1444   *
1445   * @return mixed The operation code or false
1446   */
1447  public function operationNameToOperation($operationName)
1448  {
1449      switch($operationName)
1450      {
1451          case 'added':
1452              return 1;
1453          break;
1454          case 'modified':
1455              return 2;
1456          break;
1457          case 'deleted':
1458              return 3;
1459          break;
1460      }
1461      return false;
1462  }
1463
1464  /**
1465   * Update the sync token log based on the calendar id and the
1466   * operation that was performed.
1467   *
1468   * @param string $calid The calendar ID that was modified
1469   * @param string $uri The calendar URI that was modified
1470   * @param string $operation The operation that was performed
1471   *
1472   * @return boolean True on success, otherwise false
1473   */
1474  private function updateSyncTokenLog($calid, $uri, $operation)
1475  {
1476      $currentToken = $this->getSyncTokenForCalendar($calid);
1477      $operationCode = $this->operationNameToOperation($operation);
1478      if(($operationCode === false) || ($currentToken === false))
1479          return false;
1480      $values = array($uri,
1481                      $currentToken,
1482                      $calid,
1483                      $operationCode
1484      );
1485      $sqlite = $this->getDB();
1486      if(!$sqlite)
1487        return false;
1488      $query = "INSERT INTO calendarchanges (uri, synctoken, calendarid, operation) VALUES(?, ?, ?, ?)";
1489      $res = $sqlite->query($query, $uri, $currentToken, $calid, $operationCode);
1490      if($res === false)
1491        return false;
1492      $currentToken++;
1493      $query = "UPDATE calendars SET synctoken = ? WHERE id = ?";
1494      $res = $sqlite->query($query, $currentToken, $calid);
1495      return ($res !== false);
1496  }
1497
1498  /**
1499   * Return the sync URL for a given Page, i.e. a calendar
1500   *
1501   * @param string $id The page's ID
1502   * @param string $user (optional) The user's ID
1503   *
1504   * @return mixed The sync url or false
1505   */
1506  public function getSyncUrlForPage($id, $user = null)
1507  {
1508      if(is_null($user))
1509      {
1510        if(isset($_SERVER['REMOTE_USER']) && !is_null($_SERVER['REMOTE_USER']))
1511        {
1512          $user = $_SERVER['REMOTE_USER'];
1513        }
1514        else
1515        {
1516          return false;
1517        }
1518      }
1519
1520      $calid = $this->getCalendarIdForPage($id);
1521      if($calid === false)
1522        return false;
1523
1524      $calsettings = $this->getCalendarSettings($calid);
1525      if(!isset($calsettings['uri']))
1526        return false;
1527
1528      $syncurl = DOKU_URL.'lib/plugins/davcal/calendarserver.php/calendars/'.$user.'/'.$calsettings['uri'];
1529      return $syncurl;
1530  }
1531
1532  /**
1533   * Return the private calendar's URL for a given page
1534   *
1535   * @param string $id the page ID
1536   *
1537   * @return mixed The private URL or false
1538   */
1539  public function getPrivateURLForPage($id)
1540  {
1541      // Check if this is an aggregated calendar (has multiple calendar pages)
1542      $calendarPages = $this->getCalendarPagesByMeta($id);
1543
1544      if($calendarPages !== false && count($calendarPages) > 1)
1545      {
1546          // This is an aggregated calendar - create a special private URL
1547          return $this->getPrivateURLForAggregatedCalendar($id, $calendarPages);
1548      }
1549
1550      // Single calendar - use the original logic
1551      $calid = $this->getCalendarIdForPage($id);
1552      if($calid === false)
1553        return false;
1554
1555      return $this->getPrivateURLForCalendar($calid);
1556  }
1557
1558  /**
1559   * Return the private calendar's URL for a given calendar ID
1560   *
1561   * @param string $calid The calendar's ID
1562   *
1563   * @return mixed The private URL or false
1564   */
1565  public function getPrivateURLForCalendar($calid)
1566  {
1567      if(isset($this->cachedValues['privateurl'][$calid]))
1568        return $this->cachedValues['privateurl'][$calid];
1569      $sqlite = $this->getDB();
1570      if(!$sqlite)
1571        return false;
1572      $query = "SELECT url FROM calendartoprivateurlmapping WHERE calid = ?";
1573      $row = $sqlite->queryRecord($query, [$calid]);
1574      if(!isset($row['url']))
1575      {
1576          $url = 'dokuwiki-' . bin2hex(random_bytes(16)) . '.ics';
1577          $query = "INSERT INTO calendartoprivateurlmapping (url, calid) VALUES(?, ?)";
1578          $sqlite->query($query, [$url, $calid]);
1579      }
1580      else
1581      {
1582          $url = $row['url'];
1583      }
1584
1585      $url = DOKU_URL.'lib/plugins/davcal/ics.php/'.$url;
1586      $this->cachedValues['privateurl'][$calid] = $url;
1587      return $url;
1588  }
1589
1590  /**
1591   * Return the private calendar's URL for an aggregated calendar
1592   *
1593   * @param string $id the page ID
1594   * @param array $calendarPages the calendar pages in the aggregation
1595   *
1596   * @return mixed The private URL or false
1597   */
1598  public function getPrivateURLForAggregatedCalendar($id, $calendarPages)
1599  {
1600      $aggregateId = 'aggregated-' . md5($id . serialize($calendarPages));
1601
1602      if(isset($this->cachedValues['privateurl'][$aggregateId]))
1603        return $this->cachedValues['privateurl'][$aggregateId];
1604
1605      $sqlite = $this->getDB();
1606      if(!$sqlite)
1607        return false;
1608
1609      $query = "SELECT url FROM calendartoprivateurlmapping WHERE calid = ?";
1610      $row = $sqlite->queryRecord($query, [$aggregateId]);
1611
1612      if(!isset($row['url']))
1613      {
1614          $url = 'dokuwiki-aggregated-' . bin2hex(random_bytes(16)) . '.ics';
1615          $query = "INSERT INTO calendartoprivateurlmapping (url, calid) VALUES(?, ?)";
1616          $sqlite->query($query, [$url, $aggregateId]);
1617
1618          $query = "INSERT INTO pagetocalendarmapping (page, calid) VALUES(?, ?)";
1619          $sqlite->query($query, [$id, $aggregateId]);
1620      }
1621      else
1622      {
1623          $url = $row['url'];
1624      }
1625
1626      $url = DOKU_URL.'lib/plugins/davcal/ics.php/'.$url;
1627      $this->cachedValues['privateurl'][$aggregateId] = $url;
1628      return $url;
1629  }
1630
1631  /**
1632   * Retrieve the calendar ID for a given private calendar URL
1633   *
1634   * @param string $url The private URL
1635   *
1636   * @return mixed The calendar ID or false
1637   */
1638  public function getCalendarForPrivateURL($url)
1639  {
1640      $sqlite = $this->getDB();
1641      if(!$sqlite)
1642        return false;
1643      $query = "SELECT calid FROM calendartoprivateurlmapping WHERE url = ?";
1644      $row = $sqlite->queryRecord($query, [$url]);
1645      if(!isset($row['calid']))
1646        return false;
1647      return $row['calid'];
1648  }
1649
1650  /**
1651   * Return a given calendar as ICS feed, i.e. all events in one ICS file.
1652   *
1653   * @param string $calid The calendar ID to retrieve
1654   *
1655   * @return mixed The calendar events as string or false
1656   */
1657  public function getCalendarAsICSFeed($calid)
1658  {
1659      $calSettings = $this->getCalendarSettings($calid);
1660      if($calSettings === false)
1661        return false;
1662      $events = $this->getAllCalendarEvents($calid);
1663      if($events === false)
1664        return false;
1665
1666      // Load SabreDAV
1667      require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
1668      $out = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//DAVCal//DAVCal for DokuWiki//EN\r\nCALSCALE:GREGORIAN\r\nX-WR-CALNAME:";
1669      $out .= $calSettings['displayname']."\r\n";
1670      foreach($events as $event)
1671      {
1672          $vcal = \Sabre\VObject\Reader::read($event['calendardata']);
1673          $evt = $vcal->VEVENT;
1674          $out .= $evt->serialize();
1675      }
1676      $out .= "END:VCALENDAR\r\n";
1677      return $out;
1678  }
1679
1680  /**
1681   * Return an aggregated calendar as ICS feed, combining multiple calendars.
1682   *
1683   * @param string $icsFile The ICS file name for the aggregated calendar
1684   *
1685   * @return mixed The combined calendar events as string or false
1686   */
1687  public function getAggregatedCalendarAsICSFeed($icsFile)
1688  {
1689      $sqlite = $this->getDB();
1690      if(!$sqlite)
1691        return false;
1692
1693      // Find the aggregated calendar ID from the URL
1694      $query = "SELECT calid FROM calendartoprivateurlmapping WHERE url = ?";
1695      $row = $sqlite->queryRecord($query, [$icsFile]);
1696
1697      if(!isset($row['calid']))
1698        return false;
1699
1700      $aggregateId = $row['calid'];
1701
1702      // Get the page ID for this aggregated calendar
1703      $query = "SELECT page FROM pagetocalendarmapping WHERE calid = ?";
1704      $row = $sqlite->queryRecord($query, [$aggregateId]);
1705
1706      if(!isset($row['page']))
1707        return false;
1708
1709      $pageId = $row['page'];
1710
1711      // Get the calendar pages for this aggregated calendar
1712      $calendarPages = $this->getCalendarPagesByMeta($pageId, true); // Skip permission check for private URLs
1713      if($calendarPages === false || count($calendarPages) <= 1)
1714      {
1715          if($conf['allowdebug'])
1716              \dokuwiki\Logger::error('DAVCAL', 'No calendar pages found or count <= 1 for page: '.$pageId.' (count: '.(is_array($calendarPages) ? count($calendarPages) : 'false').')', __FILE__, __LINE__);
1717          return false;
1718      }
1719
1720      // Load SabreDAV
1721      require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
1722
1723      // Start building the combined ICS
1724      $out = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//DAVCal//DAVCal for DokuWiki//EN\r\nCALSCALE:GREGORIAN\r\nX-WR-CALNAME:";
1725      $out .= "Aggregated Calendar\r\n";
1726
1727      // Combine events from all calendars
1728      foreach($calendarPages as $calPage => $color)
1729      {
1730          $calid = $this->getCalendarIdForPage($calPage);
1731          if($calid === false)
1732            continue;
1733
1734          $events = $this->getAllCalendarEvents($calid);
1735          if($events === false)
1736            continue;
1737
1738          foreach($events as $event)
1739          {
1740              $vcal = \Sabre\VObject\Reader::read($event['calendardata']);
1741              $evt = $vcal->VEVENT;
1742              $out .= $evt->serialize();
1743          }
1744      }
1745
1746      $out .= "END:VCALENDAR\r\n";
1747      return $out;
1748  }
1749
1750  /**
1751   * Retrieve a configuration option for the plugin
1752   *
1753   * @param string $key The key to query
1754   * @return mixed The option set, null if not found
1755   */
1756  public function getConfig($key)
1757  {
1758      return $this->getConf($key);
1759  }
1760
1761  /**
1762   * Parses some information from calendar objects, used for optimized
1763   * calendar-queries. Taken nearly unmodified from Sabre's PDO backend
1764   *
1765   * Returns an array with the following keys:
1766   *   * etag - An md5 checksum of the object without the quotes.
1767   *   * size - Size of the object in bytes
1768   *   * componentType - VEVENT, VTODO or VJOURNAL
1769   *   * firstOccurence
1770   *   * lastOccurence
1771   *   * uid - value of the UID property
1772   *
1773   * @param string $calendarData
1774   * @return array
1775   */
1776  protected function getDenormalizedData($calendarData)
1777  {
1778    require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
1779
1780    $vObject = \Sabre\VObject\Reader::read($calendarData);
1781    $componentType = null;
1782    $component = null;
1783    $firstOccurence = null;
1784    $lastOccurence = null;
1785    $uid = null;
1786    foreach ($vObject->getComponents() as $component)
1787    {
1788        if ($component->name !== 'VTIMEZONE')
1789        {
1790            $componentType = $component->name;
1791            $uid = (string)$component->UID;
1792            break;
1793        }
1794    }
1795    if (!$componentType)
1796    {
1797        return false;
1798    }
1799    if ($componentType === 'VEVENT')
1800    {
1801        $firstOccurence = $component->DTSTART->getDateTime()->getTimeStamp();
1802        // Finding the last occurence is a bit harder
1803        if (!isset($component->RRULE))
1804        {
1805            if (isset($component->DTEND))
1806            {
1807                $lastOccurence = $component->DTEND->getDateTime()->getTimeStamp();
1808            }
1809            elseif (isset($component->DURATION))
1810            {
1811                $endDate = clone $component->DTSTART->getDateTime();
1812                $endDate->add(\Sabre\VObject\DateTimeParser::parse($component->DURATION->getValue()));
1813                $lastOccurence = $endDate->getTimeStamp();
1814            }
1815            elseif (!$component->DTSTART->hasTime())
1816            {
1817                $endDate = clone $component->DTSTART->getDateTime();
1818                $endDate->modify('+1 day');
1819                $lastOccurence = $endDate->getTimeStamp();
1820            }
1821            else
1822            {
1823                $lastOccurence = $firstOccurence;
1824            }
1825        }
1826        else
1827        {
1828            $it = new \Sabre\VObject\Recur\EventIterator($vObject, (string)$component->UID);
1829            $maxDate = new \DateTime('2038-01-01');
1830            if ($it->isInfinite())
1831            {
1832                $lastOccurence = $maxDate->getTimeStamp();
1833            }
1834            else
1835            {
1836                $end = $it->getDtEnd();
1837                while ($it->valid() && $end < $maxDate)
1838                {
1839                    $end = $it->getDtEnd();
1840                    $it->next();
1841                }
1842                $lastOccurence = $end->getTimeStamp();
1843            }
1844        }
1845    }
1846
1847    return array(
1848        'etag'           => md5($calendarData),
1849        'size'           => strlen($calendarData),
1850        'componentType'  => $componentType,
1851        'firstOccurence' => $firstOccurence,
1852        'lastOccurence'  => $lastOccurence,
1853        'uid'            => $uid,
1854    );
1855
1856  }
1857
1858  /**
1859   * Query a calendar by ID and taking several filters into account.
1860   * This is heavily based on Sabre's PDO backend.
1861   *
1862   * @param int $calendarId The calendar's ID
1863   * @param array $filters The filter array to apply
1864   *
1865   * @return mixed The result
1866   */
1867  public function calendarQuery($calendarId, $filters)
1868  {
1869    \dokuwiki\Logger::debug('DAVCAL', 'Calendar query executed', __FILE__, __LINE__);
1870    $componentType = null;
1871    $requirePostFilter = true;
1872    $timeRange = null;
1873    $sqlite = $this->getDB();
1874    if(!$sqlite)
1875      return false;
1876
1877    // if no filters were specified, we don't need to filter after a query
1878    if (!$filters['prop-filters'] && !$filters['comp-filters'])
1879    {
1880        $requirePostFilter = false;
1881    }
1882
1883    // Figuring out if there's a component filter
1884    if (count($filters['comp-filters']) > 0 && !$filters['comp-filters'][0]['is-not-defined'])
1885    {
1886        $componentType = $filters['comp-filters'][0]['name'];
1887
1888        // Checking if we need post-filters
1889        if (!$filters['prop-filters'] && !$filters['comp-filters'][0]['comp-filters'] && !$filters['comp-filters'][0]['time-range'] && !$filters['comp-filters'][0]['prop-filters'])
1890        {
1891            $requirePostFilter = false;
1892        }
1893        // There was a time-range filter
1894        if ($componentType == 'VEVENT' && isset($filters['comp-filters'][0]['time-range']))
1895        {
1896            $timeRange = $filters['comp-filters'][0]['time-range'];
1897
1898            // If start time OR the end time is not specified, we can do a
1899            // 100% accurate mysql query.
1900            if (!$filters['prop-filters'] && !$filters['comp-filters'][0]['comp-filters'] && !$filters['comp-filters'][0]['prop-filters'] && (!$timeRange['start'] || !$timeRange['end']))
1901            {
1902                $requirePostFilter = false;
1903            }
1904        }
1905
1906    }
1907
1908    if ($requirePostFilter)
1909    {
1910        $query = "SELECT uri, calendardata FROM calendarobjects WHERE calendarid = ?";
1911    }
1912    else
1913    {
1914        $query = "SELECT uri FROM calendarobjects WHERE calendarid = ?";
1915    }
1916
1917    $values = array(
1918        $calendarId
1919    );
1920
1921    if ($componentType)
1922    {
1923        $query .= " AND componenttype = ?";
1924        $values[] = $componentType;
1925    }
1926
1927    if ($timeRange && $timeRange['start'])
1928    {
1929        $query .= " AND lastoccurence > ?";
1930        $values[] = $timeRange['start']->getTimeStamp();
1931    }
1932    if ($timeRange && $timeRange['end'])
1933    {
1934        $query .= " AND firstoccurence < ?";
1935        $values[] = $timeRange['end']->getTimeStamp();
1936    }
1937
1938    $arr = $sqlite->queryAll($query, $values);
1939
1940    $result = array();
1941    foreach($arr as $row)
1942    {
1943        if ($requirePostFilter)
1944        {
1945            if (!$this->validateFilterForObject($row, $filters))
1946            {
1947                continue;
1948            }
1949        }
1950        $result[] = $row['uri'];
1951
1952    }
1953
1954    return $result;
1955  }
1956
1957  /**
1958   * This method validates if a filter (as passed to calendarQuery) matches
1959   * the given object. Taken from Sabre's PDO backend
1960   *
1961   * @param array $object
1962   * @param array $filters
1963   * @return bool
1964   */
1965  protected function validateFilterForObject($object, $filters)
1966  {
1967      require_once(DOKU_PLUGIN.'davcal/vendor/autoload.php');
1968      // Unfortunately, setting the 'calendardata' here is optional. If
1969      // it was excluded, we actually need another call to get this as
1970      // well.
1971      if (!isset($object['calendardata']))
1972      {
1973          $object = $this->getCalendarObjectByUri($object['calendarid'], $object['uri']);
1974      }
1975
1976      $vObject = \Sabre\VObject\Reader::read($object['calendardata']);
1977      $validator = new \Sabre\CalDAV\CalendarQueryValidator();
1978
1979      $res = $validator->validate($vObject, $filters);
1980      return $res;
1981
1982  }
1983
1984  /**
1985   * Retrieve changes for a given calendar based on the given syncToken.
1986   *
1987   * @param int $calid The calendar's ID
1988   * @param int $syncToken The supplied sync token
1989   * @param int $syncLevel The sync level
1990   * @param int $limit The limit of changes
1991   *
1992   * @return array The result
1993   */
1994  public function getChangesForCalendar($calid, $syncToken, $syncLevel, $limit = null)
1995  {
1996      // Current synctoken
1997      $currentToken = $this->getSyncTokenForCalendar($calid);
1998
1999      if ($currentToken === false) return null;
2000
2001      $result = array(
2002          'syncToken' => $currentToken,
2003          'added'     => array(),
2004          'modified'  => array(),
2005          'deleted'   => array(),
2006      );
2007      $sqlite = $this->getDB();
2008      if(!$sqlite)
2009        return false;
2010
2011      if ($syncToken)
2012      {
2013
2014          $query = "SELECT uri, operation FROM calendarchanges WHERE synctoken >= ? AND synctoken < ? AND calendarid = ? ORDER BY synctoken";
2015          if ($limit > 0) $query .= " LIMIT " . (int)$limit;
2016
2017          // Fetching all changes
2018          $arr = $sqlite->queryAll($query, array($syncToken, $currentToken, $calid));
2019          $changes = array();
2020
2021          // This loop ensures that any duplicates are overwritten, only the
2022          // last change on a node is relevant.
2023          foreach($arr as $row)
2024          {
2025              $changes[$row['uri']] = $row['operation'];
2026          }
2027
2028          foreach ($changes as $uri => $operation)
2029          {
2030              switch ($operation)
2031              {
2032                  case 1 :
2033                      $result['added'][] = $uri;
2034                      break;
2035                  case 2 :
2036                      $result['modified'][] = $uri;
2037                      break;
2038                  case 3 :
2039                      $result['deleted'][] = $uri;
2040                      break;
2041              }
2042
2043          }
2044      }
2045      else
2046      {
2047          // No synctoken supplied, this is the initial sync.
2048          $query = "SELECT uri FROM calendarobjects WHERE calendarid = ?";
2049          $arr = $sqlite->queryAll($query, array($calid));
2050
2051          $result['added'] = $arr;
2052      }
2053      return $result;
2054  }
2055
2056}
2057