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