1<?php
2/**
3  * Helper Class for the webdavclient plugin
4  * This helper does the actual work.
5  *
6  * Configurable in DokuWiki's configuration
7  */
8
9// must be run within Dokuwiki
10if(!defined('DOKU_INC')) die();
11
12class helper_plugin_webdavclient extends DokuWiki_Plugin {
13
14  protected $sqlite = null;
15  protected $client = null;
16  protected $client_headers = array();
17  protected $lastErr = '';
18  protected $syncChangeLogFile;
19
20  /**
21    * Constructor to load the configuration
22    */
23  public function helper_plugin_webdavclient() {
24    global $conf;
25
26    $this->syncChangeLogFile = $conf['metadir'].'/.webdavclient/synclog';
27
28    $this->client = new DokuHTTPClient();
29    $client_headers = $this->client->headers;
30  }
31
32  /** Establish and initialize the database if not already done
33   * @return sqlite interface or false
34   */
35  private function getDB()
36  {
37      if($this->sqlite === null)
38      {
39        $this->sqlite = plugin_load('helper', 'sqlite');
40        if(!$this->sqlite)
41        {
42            dbglog('This plugin requires the sqlite plugin. Please install it.');
43            msg('This plugin requires the sqlite plugin. Please install it.', -1);
44            return false;
45        }
46        if(!$this->sqlite->init('webdavclient', DOKU_PLUGIN.'webdavclient/db/'))
47        {
48            $this->sqlite = null;
49            dbglog('Error initialising the SQLite DB for webdavclient');
50            return false;
51        }
52      }
53      return $this->sqlite;
54  }
55
56  /**
57   * Get the last error message, if any
58   *
59   * @return string The last error message
60   */
61  public function getLastError()
62  {
63      return $this->lastErr;
64  }
65
66  /**
67   * Add a new calendar entry to a given connection ID
68   *
69   * @param int $connectionId The connection ID to work with
70   * @param string $data The new calendar entry (ICS file)
71   * @param string $dwuser (Optional) The DokuWiki user
72   *
73   * @return True on success, otherwise false
74   */
75  public function addCalendarEntry($connectionId, $data, $dwuser = null)
76  {
77      $conn = $this->getConnection($connectionId);
78      if($conn === false)
79        return false;
80      $conn = array_merge($conn, $this->getCredentials($connectionId));
81      $this->setupClient($conn, strlen($data), null, 'text/calendar; charset=utf-8');
82      $path = $conn['uri'].'/'.uniqid('dokuwiki-').'.ics';
83      $resp = $this->client->sendRequest($path, $data, 'PUT');
84      if($this->client->status == 201)
85      {
86          $this->syncConnection($conn['id'], true);
87          return true;
88      }
89      $this->lastErr = 'Error adding calendar entry, server reported status '.$this->client->status;
90      return false;
91  }
92
93  /**
94   * Edit a calendar entry for a given connection ID
95   *
96   * @param int $connectionId The connection ID to work with
97   * @param string $uid The event's UID as stored internally
98   * @param string $dwuser (Optional) The DokuWiki user
99   *
100   * @return True on success, otherwise false
101   */
102  public function editCalendarEntry($connectionId, $uid, $data, $dwuser = null)
103  {
104      $conn = $this->getConnection($connectionId);
105      if($conn === false)
106        return false;
107      $conn = array_merge($conn, $this->getCredentials($connectionId));
108      $entry = $this->getCalendarEntryByUid($uid);
109      $etag = '"'.$entry['etag'].'"';
110      $this->setupClient($conn, strlen($data), null, 'text/calendar; charset=utf-8', array('If-Match' => $etag));
111      $path = $conn['uri'].'/'.$entry['uri'];
112      $resp = $this->client->sendRequest($path, $data, 'PUT');
113      if($this->client->status == 204)
114      {
115          $this->syncConnection($conn['id'], true);
116          return true;
117      }
118      $this->lastErr = 'Error editing calendar entry, server reported status '.$this->client->status;
119      return false;
120  }
121
122  /**
123   * Delete a calendar entry for a given connection ID
124   *
125   * @param int $connectionId The connection ID to work with
126   * @param string $uid The event's UID as stored internally
127   * @param string $dwuser (Optional) The DokuWiki user name
128   *
129   * @return True on success, otherwise false
130   */
131  public function deleteCalendarEntry($connectionId, $uid, $dwuser = null)
132  {
133      $conn = $this->getConnection($connectionId);
134      if($conn === false)
135        return false;
136      $conn = array_merge($conn, $this->getCredentials($connectionId));
137      $entry = $this->getCalendarEntryByUid($uid);
138      $etag = '"'.$entry['etag'].'"';
139      $this->setupClient($conn, strlen($data), null, 'text/calendar; charset=utf-8', array('If-Match' => $etag));
140      $path = $conn['uri'].'/'.$entry['uri'];
141      $resp = $this->client->sendRequest($path, '', 'DELETE');
142      if($this->client->status == 204)
143      {
144          $this->syncConnection($conn['id'], true);
145          return true;
146      }
147      $this->lastErr = 'Error deleting calendar entry, server reported status '.$this->client->status;
148      return false;
149  }
150
151  /**
152   * Retrieve a calendar entry based on UID
153   *
154   * @param string $uid The event's UID
155   *
156   * @return mixed The result
157   */
158  public function getCalendarEntryByUid($uid)
159  {
160      $sqlite = $this->getDB();
161      if(!$sqlite)
162        return false;
163      $query = "SELECT calendardata, calendarid, componenttype, etag, uri FROM calendarobjects WHERE uid = ?";
164      $res = $sqlite->query($query, $uid);
165      return $sqlite->res2row($res);
166  }
167
168    /**
169   * Retrieve a calendar entry based on connection ID and URI
170   *
171   * @param int $connectionId The connection ID
172   * @param string $uri The object's URI
173   *
174   * @return mixed The result
175   */
176  public function getCalendarEntryByUri($connectionId, $uri)
177  {
178      $sqlite = $this->getDB();
179      if(!$sqlite)
180        return false;
181      $query = "SELECT calendardata, calendarid, componenttype, etag, uri, uid FROM calendarobjects WHERE calendarid = ? AND uri = ?";
182      $res = $sqlite->query($query, $connectionId, $uri);
183      return $sqlite->res2row($res);
184  }
185
186  /**
187   * Retrieve an addressbook entry based on connection ID and URI
188   *
189   * @param int $connectionId The connection ID
190   * @param string $uri The object's URI
191   *
192   * @return mixed The result
193   */
194  public function getAddressbookEntryByUri($connectionId, $uri)
195  {
196      $sqlite = $this->getDB();
197      if(!$sqlite)
198        return false;
199      $query = "SELECT contactdata, addressbookid, etag, uri, formattedname, structuredname FROM addressbookobjects WHERE addressbookid = ? AND uri = ?";
200      $res = $sqlite->query($query, $connectionId, $uri);
201      return $sqlite->res2row($res);
202  }
203
204  /**
205   * Delete a connection, including all associated objects
206   *
207   * @param int $connectionId The connection ID to delete
208   *
209   * @return boolean True on success, otherwise false
210   */
211  public function deleteConnection($connectionId)
212  {
213      $sqlite = $this->getDB();
214      if(!$sqlite)
215        return false;
216      $conn = $this->getConnection($connectionId);
217      if($conn === false)
218        return false;
219      if($conn['type'] === 'calendar')
220      {
221          $query = "DELETE FROM calendarobjects WHERE calendarid = ?";
222          $sqlite->query($query, $connectionId);
223      }
224      elseif($conn['type'] === 'contacts')
225      {
226          $query = "DELETE FROM addressbookobjects WHERE addressbookid = ?";
227          $sqlite->query($query, $connectionId);
228      }
229      $query = "DELETE FROM connections WHERE id = ?";
230      $res = $sqlite->query($query, $connectionId);
231      if($res !== false)
232        return true;
233      $this->lastErr = "Error deleting connection.";
234      return false;
235  }
236
237  /**
238   * Retreive all calendar events for a given connection ID.
239   * A sync is NOT performed during this stage, only locally cached data
240   * are available.
241   *
242   * @param int $connectionId The connection ID to retrieve
243   * @param string $startDate The start date as a string
244   * @param string $endDate The end date as a string
245   * @param string $dwuser Unused
246   *
247   * @return An array with events
248   */
249  public function getCalendarEntries($connectionId, $startDate = null, $endDate = null, $dwuser = null)
250  {
251      $sqlite = $this->getDB();
252      if(!$sqlite)
253        return false;
254      $query = "SELECT calendardata, componenttype, uid FROM calendarobjects WHERE calendarid = ?";
255      $startTs = null;
256      $endTs = null;
257      if($startDate !== null)
258      {
259        $startTs = new \DateTime($startDate);
260        $query .= " AND lastoccurence > ".$sqlite->quote_string($startTs->getTimestamp());
261      }
262      if($endDate !== null)
263      {
264        $endTs = new \DateTime($endDate);
265        $query .= " AND firstoccurence < ".$sqlite->quote_string($endTs->getTimestamp());
266      }
267      $res = $sqlite->query($query, $connectionId);
268      return $sqlite->res2arr($res);
269  }
270
271  /**
272   * Add a new address book entry to a given connection ID
273   *
274   * @param int $connectionId The connection ID to work with
275   * @param string $data The new VCF entry
276   * @param string $dwuser (optional) The DokuWiki user (unused)
277   *
278   * @return boolean True on success, otherwise false
279   */
280  public function addAddressbookEntry($connectionId, $data, $dwuser = null)
281  {
282      $conn = $this->getConnection($connectionId);
283      if($conn === false)
284        return false;
285      $conn = array_merge($conn, $this->getCredentials($connectionId));
286      $this->setupClient($conn, strlen($data), null, 'text/vcard; charset=utf-8');
287      $path = $conn['uri'].'/'.uniqid('dokuwiki-').'.vcf';
288      $resp = $this->client->sendRequest($path, $data, 'PUT');
289      if($this->client->status == 201)
290      {
291          $this->syncConnection($conn['id'], true);
292          return true;
293      }
294      $this->lastErr = 'Error adding addressbook entry, server reported status '.$this->client->status;
295      return false;
296  }
297
298  /**
299   * Edit an address book entry for a given connection ID
300   *
301   * @param int $connectionID The connection ID to work with
302   * @param string $uri The object's URI to modify
303   * @param string $data The edited entry
304   * @param string $dwuser (Optional) The DokuWiki user
305   *
306   * @return boolean True on success, otherwise false
307   */
308  public function editAddressbookEntry($connectionId, $uri, $data, $dwuser = null)
309  {
310      $conn = $this->getConnection($connectionId);
311      if($conn === false)
312        return false;
313      $conn = array_merge($conn, $this->getCredentials($connectionId));
314      $entry = $this->getAddressbookEntryByUri($connectionId, $uri);
315      $etag = '"'.$entry['etag'].'"';
316      $this->setupClient($conn, strlen($data), null, 'text/vcard; charset=utf-8', array('If-Match' => $etag));
317      $path = $conn['uri'].'/'.$entry['uri'];
318      $resp = $this->client->sendRequest($path, $data, 'PUT');
319      if($this->client->status == 204)
320      {
321          $this->syncConnection($conn['id'], true);
322          return true;
323      }
324      $this->lastErr = 'Error editing addressbook entry, server reported status '.$this->client->status;
325      return false;
326  }
327
328  /**
329   * Delete an address book entry from a given connection ID
330   *
331   * @param int $connectionId The connection ID to work with
332   * @param string $uri The object's URI to delete
333   * @param string $dwuser (Optional) The DokuWiki user
334   *
335   * @return boolean True on success, otherwise false
336   */
337  public function deleteAddressbookEntry($connectionId, $uri, $dwuser = null)
338  {
339      $conn = $this->getConnection($connectionId);
340      if($conn === false)
341        return false;
342      $conn = array_merge($conn, $this->getCredentials($connectionId));
343      $entry = $this->getAddressbookEntryByUri($connectionId, $uri);
344      $etag = '"'.$entry['etag'].'"';
345      $this->setupClient($conn, strlen($data), null, 'text/vcard; charset=utf-8', array('If-Match' => $etag));
346      $path = $conn['uri'].'/'.$entry['uri'];
347      $resp = $this->client->sendRequest($path, '', 'DELETE');
348      if($this->client->status == 204)
349      {
350          $this->syncConnection($conn['id'], true);
351          return true;
352      }
353      $this->lastErr = 'Error deleting addressbook entry, server reported status '.$this->client->status;
354      return false;
355  }
356
357  /**
358   * Retrieve all address book entries for a given connection ID
359   *
360   * @param int $connectionId The connection ID to work with
361   * @param string $dwuser (optional) currently unused
362   *
363   * @param An array with the address book entries
364   */
365  public function getAddressbookEntries($connectionId, $dwuser = null)
366  {
367      $sqlite = $this->getDB();
368      if(!$sqlite)
369        return false;
370      $query = "SELECT contactdata, uri, formattedname, structuredname FROM addressbookobjects WHERE addressbookid = ?";
371      $res = $sqlite->query($query, $connectionId);
372      return $sqlite->res2arr($res);
373  }
374
375  /** Delete all entries from a WebDAV resource - be careful!
376   *
377   * @param int $connectionId The connection ID to work with
378   */
379  public function deleteAllEntries($connectionId)
380  {
381      $conn = $this->getConnection($connectionId);
382      switch($conn['type'])
383      {
384          case 'contacts':
385              $entries = $this->getAddressbookEntries($connectionId);
386              foreach($entries as $entry)
387              {
388                  $this->deleteAddressbookEntry($connectionId, $entry['uri']);
389              }
390          break;
391          case 'calendar':
392              $entries = $this->getCalendarEntries($connectionId);
393              foreach($entries as $entry)
394              {
395                  $this->deleteCalendarEntry($connectionId, $entry['uid']);
396              }
397          break;
398      }
399      return true;
400  }
401
402  /**
403   * Add a new WebDAV connection to the backend
404   *
405   * @param string $uri The URI of the new ressource
406   * @param string $username The username for logging in
407   * @param string $password The password for logging in
408   * @param string $displayname The displayname of the ressource
409   * @param string $description The description of the ressource
410   * @param string $type The connection type, can be 'contacts' or 'calendar'
411   * @param int $syncinterval The sync interval in seconds
412   * @param boolean $active (optional) If the connection is active, defaults to true
413   * @param string $dwuser (optional) currently unused
414   *
415   * @return true on success, otherwise false
416   */
417  public function addConnection($uri, $username, $password, $displayname, $description, $type, $syncinterval = '3600', $write = false, $active = true, $dwuser = null)
418  {
419      $sqlite = $this->getDB();
420      if(!$sqlite)
421        return false;
422      $query = "INSERT INTO connections (uri, displayname, description, username, password, dwuser, type, syncinterval, lastsynced, active, write) ".
423               "VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);";
424      $res = $sqlite->query($query, $uri, $displayname, $description, $username, $password, $dwuser, $type, $syncinterval, '0', $active ? '1' : '0', $write ? '1' : '0');
425      if($res === false)
426      {
427        $this->lastErr = "Error inserting values into SQLite DB";
428        return false;
429      }
430
431      // Retrieve the connection ID
432      $query = "SELECT id FROM connections WHERE uri = ? AND displayname = ? AND description = ? AND username = ? AND password = ? AND dwuser = ? AND type = ? and syncinterval = ? and lastsynced = 0 AND active = ? AND write = ?";
433      $res = $sqlite->query($query, $uri, $displayname, $description, $username, $password, $dwuser, $type, $syncinterval, $active, $write);
434      $row = $sqlite->res2row($res);
435
436      if(isset($row['id']))
437        return $row['id'];
438
439      $this->lastErr = "Error retrieving new connection ID from SQLite DB";
440      return false;
441  }
442
443  /**
444   * Modify an existing connection, overwriting previously defined values
445   *
446   * @param int $connId The connection ID to modify
447   * @param string $permission The page to take the permissions from
448   * @param string $displayname The new Display Name
449   * @param string $syncinterval The sync interval
450   * @param string $write If it should be writable
451   * @param string $active If it is active
452   *
453   * @return boolean True on success, otherwise false
454   */
455  public function modifyConnection($connId, $permission, $displayname, $syncinterval, $write, $active)
456  {
457      $sqlite = $this->getDB();
458      if(!$sqlite)
459        return false;
460      $query = "UPDATE connections SET permission = ?, displayname = ?, syncinterval = ?, write = ?, active = ? WHERE id = ?";
461      $res = $sqlite->query($query, $permission, $displayname, $syncinterval, $write, $active, $connId);
462      if($res !== false)
463        return true;
464
465      $this->lastErr = "Error modifying connection information";
466      return false;
467  }
468
469  /**
470   * Retrieve information about all configured connections
471   *
472   * @return An array containing the connection information
473   */
474  public function getConnections()
475  {
476      $sqlite = $this->getDB();
477      if(!$sqlite)
478        return false;
479      $query = "SELECT id, uri, displayname, description, synctoken, dwuser, type, syncinterval, lastsynced, ctag, active, write, permission FROM connections";
480      $res = $sqlite->query($query);
481      return $sqlite->res2arr($res);
482  }
483
484  /**
485   * Retrieve information about a specific connection
486   *
487   * @param int $connectionId The connection ID to retrieve
488   *
489   * @return An array containing the connection information
490   */
491  public function getConnection($connectionId)
492  {
493      $sqlite = $this->getDB();
494      if(!$sqlite)
495        return false;
496      $query = "SELECT id, uri, displayname, description, synctoken, dwuser, type, syncinterval, lastsynced, ctag, active, write, permission FROM connections WHERE id = ?";
497      $res = $sqlite->query($query, $connectionId);
498      return $sqlite->res2row($res);
499  }
500
501  /**
502   * Retrieve the credentials for a given connection by its ID.
503   *
504   * @param int $connectionId The connection ID to retrieve
505   *
506   * @return An array containing the username and password
507   */
508  public function getCredentials($connectionId)
509  {
510      $sqlite = $this->getDB();
511      if(!$sqlite)
512        return false;
513      $query = "SELECT username, password FROM connections WHERE id = ?";
514      $res = $sqlite->query($query, $connectionId);
515      return $sqlite->res2row($res);
516  }
517
518  /**
519   * Query a Server and do WebDAV auto discovery.
520   * Currently, we do the follwing:
521   *   1) Do a PROPFIND on / and try to follow .well-known URLs
522   *   2) Prefer HTTPS wherever possible
523   *   3) Uniquify the results
524   *   4) Do a PROPFIND on the resulting URLs for the calendars/addressbook homes
525   *   5) Do a PROPFIND on the resulting home sets for calendars/addressbooks
526   *   6) Filter for calendards/addressbooks we support and
527   *   7) Return the results
528   *
529   * @param string $uri The URI of the server
530   * @param string $username The username for login
531   * @param string $password The password for login
532   *
533   * @return An array containing calendards and addressbooks that were found
534   */
535  public function queryServer($uri, $username, $password)
536  {
537      global $conf;
538      dbglog('queryServer: '.$uri);
539
540      $webdavobjects = array();
541      $webdavobjects['calendars'] = array();
542      $webdavobjects['addressbooks'] = array();
543
544      // Remove the scheme, if given
545      $pos = strpos($uri, '//');
546      if($pos !== false)
547        $uri = substr($uri, $pos+2);
548
549      // We try the given URL, https first
550      // as well as the .well-known URLs
551      $urilist = array('https://'.$uri, 'http://'.$uri,
552                       'https://'.$uri.'/.well-known/caldav',
553                       'http://'.$uri.'/.well-known/caldav',
554                       'https://'.$uri.'/.well-known/carddav',
555                       'http://'.$uri.'/.well-known/carddav');
556
557      $discoveredUris = array();
558      $max_redir = 3;
559      $redirects = 0;
560      $data = $this->buildPropfind(array('D:current-user-principal'));
561      $conn = array();
562      $conn['uri'] = $urilist[0];
563      $conn['username'] = $username;
564      $conn['password'] = $password;
565      $this->setupClient($conn, strlen($data), ' 0',
566                         'application/xml; charset=utf-8', array(),
567                         0); // Don't follow redirects here
568
569      // Try all URLs, following up to 3 redirects and sending
570      // a PROPFIND each time
571      while(count($urilist) > 0)
572      {
573          $uri = array_shift($urilist);
574          $this->client->sendRequest($uri, $data, 'PROPFIND');
575          switch($this->client->status)
576          {
577              case 301:
578              case 302:
579              case 303:
580              case 307:
581              case 308:
582                // We follow the redirect with a PROPFIND, even if this is INVALID as per RFC
583                dbglog('Redirect, following...');
584                if($redirects < $max_redir)
585                {
586                    array_unshift($urilist, $this->client->resp_headers['location']);
587                    $redirects++;
588                }
589                break;
590              case 207:
591                // This is a success
592                $redirects = 0;
593                dbglog('Found!');
594                $response = $this->parseResponse();
595                foreach($response as $href => $params)
596                {
597                    $components = parse_url($uri);
598                    if(isset($params['current-user-principal']['href']))
599                      $discoveredUris[] = $components['scheme'].'://'.
600                                          $components['host'].
601                                          $params['current-user-principal']['href'];
602                }
603                break;
604              default:
605                $redirects = 0;
606                dbglog('Probably an error, continuing...');
607                break;
608          }
609      }
610      // Remove Duplicates
611      $discoveredUris = $this->postprocessUris($discoveredUris);
612      $calendarhomes = array();
613      $addressbookhomes = array();
614
615      // Go through all discovered URLs and do a PROPFIND for calendar-home-set
616      // and for addressbook-home-set
617      foreach($discoveredUris as $uri)
618      {
619          $data = $this->buildPropfind(array('C:calendar-home-set'),
620                                       array('C' => 'urn:ietf:params:xml:ns:caldav'));
621          $this->setupClient($conn, strlen($data), ' 0');
622          $this->client->sendRequest($uri, $data, 'PROPFIND');
623          if($this->client->status == 207)
624          {
625              $response = $this->parseResponse();
626              foreach($response as $href => $params)
627              {
628                if(isset($params['calendar-home-set']['href']))
629                {
630                    $components = parse_url($uri);
631                    $calendarhomes[] = $components['scheme'].'://'.
632                                       $components['host'].
633                                       $params['calendar-home-set']['href'];
634                }
635              }
636          }
637
638          $data = $this->buildPropfind(array('C:addressbook-home-set'),
639                                       array('C' => 'urn:ietf:params:xml:ns:carddav'));
640          $this->setupClient($conn, strlen($data), ' 0');
641          $this->client->sendRequest($uri, $data, 'PROPFIND');
642          if($this->client->status == 207)
643          {
644              $response = $this->parseResponse();
645              foreach($response as $href => $params)
646              {
647                if(isset($params['addressbook-home-set']['href']))
648                {
649                  $components = parse_url($uri);
650                  $addressbookhomes[] = $components['scheme'].'://'.
651                                        $components['host'].
652                                        $params['addressbook-home-set']['href'];
653                }
654              }
655          }
656      }
657
658      // Now we need to query the addressbook list for address books
659      // and the calendar list for calendars
660
661      foreach($calendarhomes as $uri)
662      {
663          $data = $this->buildPropfind(array('D:resourcetype', 'D:displayname',
664                                      'CS:getctag', 'C:supported-calendar-component-set'),
665                                      array('C' => 'urn:ietf:params:xml:ns:caldav',
666                                      'CS' => 'http://calendarserver.org/ns/'));
667          $this->setupClient($conn, strlen($data), '1');
668          $this->client->sendRequest($uri, $data, 'PROPFIND');
669          $response = $this->parseResponse();
670          $webdavobjects['calendars'] = $this->getSupportedCalendarsFromDavResponse($uri, $response);
671      }
672
673      foreach($addressbookhomes as $uri)
674      {
675          $data = $this->buildPropfind(array('D:resourcetype', 'D:displayname', 'CS:getctag'),
676                                      array('CS' => 'http://calendarserver.org/ns/'));
677          $this->setupClient($conn, strlen($data), '1');
678          $this->client->sendRequest($uri, $data, 'PROPFIND');
679          $response = $this->parseResponse();
680          $webdavobjects['addressbooks'] = $this->getSupportedAddressbooksFromDavResponse($uri, $response);
681      }
682
683      return $webdavobjects;
684
685  }
686
687  /**
688   * Filter the DAV response by calendars we support
689   *
690   * @param string $uri The request URI where the PROPFIND was done
691   * @param array $response The response from the PROPFIND
692   *
693   * @return array An array containing URL => Displayname
694   */
695  private function getSupportedCalendarsFromDavResponse($uri, $response)
696  {
697      $calendars = array();
698      foreach($response as $href => $data)
699      {
700          if(!isset($data['resourcetype']['calendar']))
701            continue;
702          if(!isset($data['supported-calendar-component-set']['comp']['name']))
703            continue;
704          if((is_array($data['supported-calendar-component-set']['comp']['name']) &&
705             !in_array('VEVENT', $data['supported-calendar-component-set']['comp']['name'])) ||
706             (!is_array($data['supported-calendar-component-set']['comp']['name']) &&
707             $data['supported-calendar-component-set']['comp']['name'] != 'VEVENT'))
708            continue;
709
710          $components = parse_url($uri);
711          $href = $components['scheme'].'://'.$components['host'].$href;
712          $calendars[$href] = $data['displayname'];
713      }
714      return $calendars;
715  }
716
717  /**
718   * Filter the DAV response by addressbooks we support
719   *
720   * @param string $uri The request URI where the PROPFIND was done
721   * @param array $response The response from the PROPFIND
722   *
723   * @return array An array containing URL => Displayname
724   */
725  private function getSupportedAddressbooksFromDavResponse($uri, $response)
726  {
727      $addressbooks = array();
728      foreach($response as $href => $data)
729      {
730          if(!isset($data['resourcetype']['addressbook']))
731            continue;
732          $components = parse_url($uri);
733          $href = $components['scheme'].'://'.$components['host'].$href;
734          $addressbooks[$href] = $data['displayname'];
735      }
736      return $addressbooks;
737  }
738
739  /**
740   * Remove duplicate URLs from the list and prefere HTTPS if both are given
741   *
742   * @param array $urilist The list of URLs to process
743   *
744   * @return array The processed URI list
745   */
746  private function postprocessUris($urilist)
747  {
748      $discoveredUris = array();
749      foreach($urilist as $uri)
750      {
751          $href = str_replace(array('http', 'https'), '', $uri);
752          if(in_array('http'.$href, $urilist) && in_array('https'.$href, $urilist))
753          {
754              if(!in_array('https'.$href, $discoveredUris))
755                $discoveredUris[] = 'https'.$href;
756          }
757          else
758          {
759              if(!in_array($uri, $discoveredUris))
760                $discoveredUris[] = $uri;
761          }
762      }
763      return $discoveredUris;
764  }
765
766  /**
767   * Sync a single connection if required.
768   * Sync requirement is checked based on
769   *   1) Time
770   *   2) CTag
771   *   3) ETag
772   *
773   * @param int $connectionId The connection ID to work with
774   * @param boolean $force Force sync, even if the interval hasn't passed
775   * @param boolean $overrideActive Force sync, even if the connection is inactive
776   * @param boolean $deleteBeforeSync Force sync AND delete local data beforehand
777   *
778   * @return true on success, otherwise false
779   */
780  public function syncConnection($connectionId, $force = false, $overrideActive = false,
781                                 $deleteBeforeSync = false)
782  {
783      global $conf;
784      dbglog('syncConnection: '.$connectionId);
785      $conn = $this->getConnection($connectionId);
786      if($conn === false)
787      {
788        $this->lastErr = "Error retrieving connection information from SQLite DB";
789        return false;
790      }
791      $conn = array_merge($conn, $this->getCredentials($connectionId));
792
793      dbglog('Got connection information for connectionId: '.$connectionId);
794
795      // Sync required?
796      if((time() < ($conn['lastsynced'] + $conn['syncinterval'])) && !$force)
797      {
798        dbglog('Sync not required (time)');
799        $this->lastErr = "Sync not required (time)";
800        return false;
801      }
802
803      // Active?
804      if(($conn['active'] !== '1') && !$overrideActive)
805      {
806        dbglog('Connection not active.');
807        $this->lastErr = "Connection not active";
808        return false;
809      }
810
811      dbglog('Sync required for ConnectionID: '.$connectionId);
812
813      if(($conn['type'] !== 'contacts') && ($conn['type'] !== 'calendar')
814        && ($conn['type'] !== 'icsfeed'))
815      {
816        $this->lastErr = "Unsupported connection type found: ".$conn['type'];
817        return false;
818      }
819
820      // Check if we are dealing with an ICS feed
821
822      if($conn['type'] === 'icsfeed')
823      {
824        return $this->syncConnectionFeed($conn, $force);
825      }
826      else
827      {
828        // Perform the sync
829        return $this->syncConnectionDAV($conn, $force, $deleteBeforeSync);
830      }
831  }
832
833  /**
834   * Sync connection as ICS Feed
835   * @param $conn array the connection parameters
836   * @param $force bool force syncing even if it's not required
837   * @return true on success, otherwise false
838   */
839  private function syncConnectionFeed($conn, $force = false)
840  {
841    dbglog('Sync ICS Feed');
842    $this->setupClient($conn, null, null, null);
843    $resp = $this->client->sendRequest($conn['uri']);
844    if(($this->client->status >= 400) || ($this->client->status < 200))
845    {
846      dbglog('Error: Status reported was ' . $this->client->status);
847      $this->lastErr = "Error: Server reported status ".$this->client->status;
848      return false;
849    }
850    $caldata = $this->client->resp_body;
851    dbglog($caldata);
852    require_once(DOKU_PLUGIN.'webdavclient/vendor/autoload.php');
853
854    $vObject = \Sabre\VObject\Reader::read($caldata);
855
856    $sqlite = $this->getDB();
857    if(!$sqlite)
858      return false;
859
860    $sqlite->query("BEGIN TRANSACTION");
861    // Delete local entries
862    $query = "DELETE FROM calendarobjects WHERE calendarid = ?";
863    $sqlite->query($query, $conn['id']);
864
865    foreach ($vObject->getComponents() as $component)
866    {
867        $componentType = $component->name;
868        if($componentType === 'VEVENT')
869        {
870            $calendarData = array();
871            $calendarObject = new \Sabre\VObject\Component\VCalendar();
872            $calendarObject->add($component);
873            $calendarData['calendar-data'] = $calendarObject->serialize();
874            $calendarData['href'] = $conn['uri'];
875            $calendarData['getetag'] = md5($calendarData['calendar-data']);
876            $this->object2calendar($conn['id'], $calendarData);
877        }
878    }
879    $sqlite->query("COMMIT TRANSACTION");
880
881    $this->updateConnection($conn['id'], time());
882    return true;
883  }
884
885  /**
886   * Sync connection via DAV
887   * @param $conn array the connection parameters
888   * @param $force bool force syncing even if it's not required
889   * @param $deleteBeforeSync bool delete calendar entries before sync
890   * @return true on success, otherwise false
891   */
892  private function syncConnectionDAV($conn, $force = false,
893                                     $deleteBeforeSync = false)
894  {
895      $syncResponse = $this->getCollectionStatusForConnection($conn);
896
897      // If the server supports getctag, we can check using the ctag if something has changed
898
899      if(isset($syncResponse['getctag']))
900      {
901          if(($conn['ctag'] === $syncResponse['getctag']) && !$force)
902          {
903            dbglog('CTags match, no need to sync');
904            $this->updateConnection($conn['id'], time(), $conn['ctag']);
905            $this->lastErr = "CTags match, there is no need to sync";
906            return false;
907          }
908      }
909
910      // Get etags and check if the etags match our existing etags
911      // This also works if the ctag is not supported
912
913      $remoteEtags = $this->getRemoteETagsForConnection($conn);
914      dbglog($remoteEtags);
915      if($remoteEtags === false)
916      {
917        $this->lastErr = "Fetching ETags from remote server failed.";
918        return false;
919      }
920
921      $sqlite = $this->getDB();
922      if(!$sqlite)
923        return false;
924
925      // Delete all local entries if requested to do so
926      if($deleteBeforeSync === true)
927      {
928        if($conn['type'] === 'calendar')
929        {
930          $query = "DELETE FROM calendarobjects WHERE calendarid = ?";
931          $sqlite->query($query, $conn['id']);
932        }
933        elseif($conn['type'] === 'contacts')
934        {
935          $query = "DELETE FROM addressbookobjects WHERE addressbookid = ?";
936          $sqlite->query($query, $conn['id']);
937        }
938      }
939
940      $localEtags = $this->getLocalETagsForConnection($conn);
941      dbglog($localEtags);
942      if($localEtags === false)
943      {
944        $this->lastErr = "Fetching ETags from local database failed.";
945        return false;
946      }
947
948      $worklist = $this->compareETags($remoteEtags, $localEtags);
949      dbglog($worklist);
950
951      $sqlite->query("BEGIN TRANSACTION");
952
953      // Fetch the etags that need to be fetched
954      if(!empty($worklist['fetch']))
955      {
956        $objects = $this->getRemoteObjectsByEtag($conn, $worklist['fetch']);
957        if($objects === false)
958        {
959          $this->lastErr = "Fetching remote objects by ETag failed.";
960          $sqlite->query("ROLLBACK TRANSACTION");
961          return false;
962        }
963        dbglog($objects);
964        $this->insertObjects($conn, $objects);
965      }
966
967      // Delete the etags that need to be deleted
968      if(!empty($worklist['del']))
969      {
970        $this->deleteEntriesByETag($conn, $worklist['del']);
971      }
972
973      $sqlite->query("COMMIT TRANSACTION");
974
975      $this->updateConnection($conn['id'], time(), $syncResponse['getctag']);
976
977      return true;
978  }
979
980  /**
981   * Insert a DAV object into the local cache database
982   *
983   * @param array $conn The connection to work with
984   * @param array $objects A list of objects to insert
985   *
986   * @return True
987   */
988  private function insertObjects($conn, $objects)
989  {
990      foreach($objects as $href => $data)
991      {
992        $data['href'] = basename($href);
993        if($conn['type'] === 'calendar')
994        {
995          $this->object2calendar($conn['id'], $data);
996        }
997        elseif($conn['type'] === 'contacts')
998        {
999          $this->object2addressbook($conn['id'], $data);
1000        }
1001        else
1002        {
1003          $this->lastErr = "Unsupported type.";
1004          return false;
1005        }
1006      }
1007      return true;
1008  }
1009
1010  /**
1011   * Delete entries from the local cache DB by ETag
1012   *
1013   * @param array $conn The connection to work with
1014   * @param array $worklist An array of etags
1015   *
1016   * @return True on success, otherwise false
1017   */
1018  private function deleteEntriesByETag($conn, $worklist)
1019  {
1020      if($conn['type'] === 'calendar')
1021      {
1022          $table = 'calendarobjects';
1023          $filter = 'calendarid';
1024      }
1025      elseif($conn['type'] === 'contacts')
1026      {
1027          $table = 'addressbookobjects';
1028          $filter = 'addressbookid';
1029      }
1030      else
1031      {
1032          $this->lastErr = "Unsupported type.";
1033          return false;
1034      }
1035      $sqlite = $this->getDB();
1036      if(!$sqlite)
1037        return false;
1038      foreach($worklist as $etag => $href)
1039      {
1040        $query = "DELETE FROM " . $table . " WHERE etag = ? AND " . $filter . " = ?";
1041        $sqlite->query($query, $etag, $conn['id']);
1042      }
1043      return true;
1044  }
1045
1046  /**
1047   * Fetch remote DAV objects by ETag
1048   *
1049   * @param array $conn The connection to work with
1050   * @param array $etags An array of etags to retrieve
1051   *
1052   * @return array The parsed response as array
1053   */
1054  private function getRemoteObjectsByEtag($conn, $etags)
1055  {
1056      if($conn['type'] === 'contacts')
1057      {
1058        $data = $this->buildReport('C:addressbook-multiget', array('C' => 'urn:ietf:params:xml:ns:carddav'),
1059                                   array('D:getetag',
1060                                   'C:address-data'), array(),
1061                                   array_values($etags));
1062      }
1063      elseif($conn['type'] === 'calendar')
1064      {
1065        $data = $this->buildReport('C:calendar-multiget', array('C' => 'urn:ietf:params:xml:ns:caldav'),
1066                                   array('D:getetag',
1067                                   'C:calendar-data'),
1068                                   array(),
1069                                   array_values($etags));
1070      }
1071      $this->setupClient($conn, strlen($data), '1');
1072      $resp = $this->client->sendRequest($conn['uri'], $data, 'REPORT');
1073      $response = $this->parseResponse();
1074      return $response;
1075  }
1076
1077  /**
1078   * Compare a local and a remote list of etags and return delete and fetch lists
1079   *
1080   * @param array $remoteEtags Array with the remot ETags
1081   * @param array $localEtags Array with the local ETags
1082   *
1083   * @return array An Array containing a 'del' and a 'fetch' list
1084   */
1085  private function compareETags($remoteEtags, $localEtags)
1086  {
1087      $lEtags = array();
1088      $rEtags = array();
1089      $data = array();
1090      $data['del'] = array();
1091      $data['fetch'] = array();
1092
1093      foreach($localEtags as $localEtag)
1094        $lEtags[$localEtag['etag']] = $localEtag['uri'];
1095
1096      foreach($remoteEtags as $href => $etag)
1097      {
1098          $rEtags[$etag['getetag']] = $href;
1099      }
1100
1101      $data['del'] = array_diff_key($lEtags, $rEtags);
1102      $data['fetch'] = array_diff_key($rEtags, $lEtags);
1103
1104      return $data;
1105  }
1106
1107  /**
1108   * Internal function to set up the DokuHTTPClient for data retrieval
1109   *
1110   * @param array $conn The connection information
1111   * @param string $cl (Optional) The Content-Length parameter
1112   * @param string $depth (Optional) The Depth parameter
1113   * @param string $ct (Optional) The Content-Type
1114   * @param array $headers (Optional) Additional headers
1115   * @param int $redirect (Optional) Number of redirects to follow
1116   */
1117  private function setupClient($conn, $cl = null, $depth = null,
1118                               $ct = 'application/xml; charset=utf-8', $headers = array(),
1119                               $redirect = 3)
1120  {
1121      $this->client->user = $conn['username'];
1122      $this->client->pass = $conn['password'];
1123      $this->client->http = '1.1';
1124      $this->client->max_redirect = $redirect;
1125      // For big request, having keep alive enabled doesn't seem to work correctly
1126      $this->client->keep_alive = false;
1127      // Restore the Client's default headers, otherwise we might keep
1128      // old headers for later requests
1129      $this->client->headers = $this->client_headers;
1130      foreach($headers as $header => $content)
1131        $this->client->headers[$header] = $content;
1132      if(!is_null($ct))
1133        $this->client->headers['Content-Type'] = $ct;
1134      if(!is_null($depth))
1135        $this->client->headers['Depth'] = $depth;
1136      if(!is_null($cl))
1137        $this->client->headers['Content-Length'] = $cl;
1138
1139  }
1140
1141  /**
1142   * Retrieve the local ETags for a given connection
1143   *
1144   * @param array $conn The local connection
1145   *
1146   * @return array An array containing the ETags
1147   */
1148  private function getLocalETagsForConnection($conn)
1149  {
1150      if($conn['type'] === 'calendar')
1151      {
1152          $table = 'calendarobjects';
1153          $id = 'calendarid';
1154      }
1155      elseif($conn['type'] === 'contacts')
1156      {
1157          $table = 'addressbookobjects';
1158          $id = 'addressbookid';
1159      }
1160      else
1161      {
1162          $this->lastErr = "Unsupported type.";
1163          return false;
1164      }
1165      $sqlite = $this->getDB();
1166      if(!$sqlite)
1167        return false;
1168      $query = "SELECT uri, etag FROM " . $table . " WHERE " . $id . " = ?";
1169      $res = $sqlite->query($query, $conn['id']);
1170      $data = $sqlite->res2arr($res);
1171      return $data;
1172  }
1173
1174  /**
1175   * Retrieve the remote ETags for a given connection
1176   *
1177   * @param array $conn The connection to work with
1178   *
1179   * @return array An array of remote ETags
1180   */
1181  private function getRemoteETagsForConnection($conn)
1182  {
1183      global $conf;
1184      if($conn['type'] === 'contacts')
1185      {
1186        $data = $this->buildReport('C:addressbook-query', array('C' => 'urn:ietf:params:xml:ns:carddav'),
1187                                   array('D:getetag'));
1188      }
1189      elseif($conn['type'] === 'calendar')
1190      {
1191        $data = $this->buildReport('C:calendar-query', array('C' => 'urn:ietf:params:xml:ns:caldav'),
1192                                   array('D:getetag'),
1193                                   array('C:comp-filter' => array('VCALENDAR' => 'VEVENT')));
1194      }
1195      else
1196      {
1197          $this->lastErr = "Unsupported type.";
1198          return false;
1199      }
1200      $this->setupClient($conn, strlen($data), '1');
1201      $resp = $this->client->sendRequest($conn['uri'], $data, 'REPORT');
1202      $etags = $this->parseResponse();
1203      return $etags;
1204  }
1205
1206  /**
1207   * Parse a remote response and check the status of the response objects
1208   *
1209   * @return mixed An array with the response objects or false
1210   */
1211  private function parseResponse()
1212  {
1213      global $conf;
1214      if(($this->client->status >= 400) || ($this->client->status < 200))
1215      {
1216          dbglog('Error: Status reported was ' . $this->client->status);
1217          $this->lastErr = "Error: Server reported status ".$this->client->status;
1218          return false;
1219      }
1220
1221      dbglog($this->client->status);
1222      dbglog($this->client->error);
1223
1224      $response = $this->clean_response($this->client->resp_body);
1225      try
1226      {
1227        $xml = simplexml_load_string($response);
1228      }
1229      catch(Exception $e)
1230      {
1231        dbglog('Exception occured: '.$e->getMessage());
1232        $this->lastErr = "Exception occured while parsing response: ".$e->getMessage();
1233        return false;
1234      }
1235
1236      $data = array();
1237
1238      if(!empty($xml->response))
1239      {
1240          foreach($xml->response as $response)
1241          {
1242              $href = (string)$response->href;
1243              $status = $this->parseHttpStatus((string)$response->propstat->status);
1244              $data[$href]['status'] = $status;
1245              // We parse here all props that succeeded and ignore the failed ones
1246              if($status === '200')
1247              {
1248                  $data[$href] = $this->recursiveXmlToArray($response->propstat->prop->children());
1249
1250              }
1251          }
1252      }
1253      return $data;
1254  }
1255
1256  /**
1257   * Recursively convert XML Tags and attributes to an array
1258   *
1259   * @param mixed $objects SimpleXML Objects to recurse
1260   *
1261   * @return array An array containing the processed data
1262   */
1263  private function recursiveXmlToArray($objects)
1264  {
1265      $ret = array();
1266      foreach($objects as $object)
1267      {
1268          // If our object contains child objects, call ourselves again
1269          if($object->count() > 0)
1270          {
1271            $ret[$object->getName()] = $this->recursiveXmlToArray($object->children());
1272          }
1273          // If our object has attributes, extract the attributes
1274          elseif(!is_null($object->attributes()) && (count($object->attributes()) > 0))
1275          {
1276            // This is the hardest part: sometimes, attributes
1277            // have the same name. Parse them as arrays
1278            if(!is_array($ret[$object->getName()]))
1279              $ret[$object->getName()] = array();
1280            foreach($object->attributes() as $key => $val)
1281            {
1282              // If our existing value is not yet an array,
1283              // convert it to an array and add the new value
1284              if(isset($ret[$object->getName()][(string)$key]) &&
1285                !is_array($ret[$object->getName()][(string)$key]))
1286              {
1287                $ret[$object->getName()][(string)$key] =
1288                        array($ret[$object->getName()][(string)$key], trim((string)$val, '"'));
1289              }
1290              // If it is already an array, simply append to an array
1291              elseif(isset($ret[$object->getName()][(string)$key]) &&
1292                is_array($ret[$object->getName()][(string)$key]))
1293              {
1294                $ret[$object->getName()][(string)$key][] = trim((string)$val, '"');
1295              }
1296              // If the key doesn't exist, add it to output as string
1297              // as we don't know yet if there are further values
1298              else
1299              {
1300                $ret[$object->getName()][(string)$key] = trim((string)$val, '"');
1301              }
1302            }
1303          }
1304          // Simply add the object's value to the output
1305          else
1306          {
1307            $ret[$object->getName()] = trim((string)$object, '"');
1308          }
1309      }
1310      return $ret;
1311  }
1312
1313  /**
1314   * Retrieve the status of a collection
1315   *
1316   * @param array $conn An array containing connection information
1317   * @return an Array containing the status
1318   */
1319  private function getCollectionStatusForConnection($conn)
1320  {
1321      global $conf;
1322      $data = $this->buildPropfind(array('D:displayname', 'CS:getctag', 'D:sync-token'), array('CS' => 'http://calendarserver.org/ns/'));
1323      $this->setupClient($conn, strlen($data), ' 0');
1324
1325      $resp = $this->client->sendRequest($conn['uri'], $data, 'PROPFIND');
1326
1327      $response = $this->parseResponse();
1328      if((count($response) != 1) || ($response === false))
1329      {
1330          $this->lastErr = "Error: Unexpected response from server";
1331          return array();
1332      }
1333
1334      $syncResponse = array_values($response);
1335      $syncResponse = $syncResponse[0];
1336      return $syncResponse;
1337  }
1338
1339  /**
1340   * Update the status of a connection
1341   *
1342   * @param int $connectionId The connection ID to work with
1343   * @param int $lastSynced The last time the connection was synnchronised
1344   * @param string $ctag (optional) The CTag of the current sync run
1345   *
1346   * @return true on success, otherwise false
1347   */
1348  private function updateConnection($connectionId, $lastSynced, $ctag = null)
1349  {
1350      if(is_null($ctag))
1351        $ctag = '';
1352
1353      io_saveFile($this->syncChangeLogFile.$connectionId, serialize($lastSynced));
1354
1355      $sqlite = $this->getDB();
1356      if(!$sqlite)
1357        return false;
1358      $query = "UPDATE connections SET lastsynced = ?, ctag = ? WHERE id = ?";
1359      $res = $sqlite->query($query, $lastSynced, $ctag, $connectionId);
1360      if($res !== false)
1361        return true;
1362      $this->lastErr = "Error updating connection";
1363      return false;
1364  }
1365
1366  /**
1367   * Convert a given calendar object (including ICS data) and save
1368   * it in the local database
1369   *
1370   * @param int $connectionId The connection ID to work with
1371   * @param array $calendarobject The calendar object to convert
1372   *
1373   * @return true on success, otherwise false
1374   */
1375  private function object2calendar($connectionId, $calendarobject)
1376  {
1377      $extradata = $this->getDenormalizedCalendarData($calendarobject['calendar-data']);
1378
1379      if($extradata === false)
1380      {
1381        $this->lastErr = "Couldn't parse calendar data";
1382        return false;
1383      }
1384
1385      $sqlite = $this->getDB();
1386      if(!$sqlite)
1387        return false;
1388      $query = "INSERT INTO calendarobjects (calendardata, uri, calendarid, lastmodified, etag, size, componenttype, uid, firstoccurence, lastoccurence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
1389      $lastmod = new \DateTime($calendarobject['getlastmodified']);
1390      $res = $sqlite->query($query, $calendarobject['calendar-data'], $calendarobject['href'], $connectionId, $lastmod->getTimestamp(), $calendarobject['getetag'], $extradata['size'], $extradata['componentType'], $extradata['uid'], $extradata['firstOccurence'], $extradata['lastOccurence']);
1391      if($res !== false)
1392        return true;
1393      $this->lastErr = "Error inserting object";
1394      return false;
1395  }
1396
1397  /**
1398   * Convert a given address book object (including VCF data) and save it in
1399   * the local database
1400   *
1401   * @param int $connectionId The ID of the connection to work with
1402   * @param array $addressobject The address object data
1403   *
1404   * @return true on success, otherwise false
1405   */
1406  private function object2addressbook($connectionId, $addressobject)
1407  {
1408      $extradata = $this->getDenormalizedContactData($addressobject['address-data']);
1409
1410      if($extradata === false)
1411      {
1412        $this->lastErr = "Couldn't parse contact data";
1413        return false;
1414      }
1415
1416      $sqlite = $this->getDB();
1417      if(!$sqlite)
1418        return false;
1419      $query = "INSERT INTO addressbookobjects (contactdata, uri, addressbookid, "
1420              ."lastmodified, etag, size, formattedname, structuredname) "
1421              ."VALUES(?, ?, ?, ?, ?, ?, ?, ?)";
1422      $lastmod = new \DateTime($addressobject['getlastmodified']);
1423      $res = $sqlite->query($query,
1424                                  $addressobject['address-data'],
1425                                  $addressobject['href'],
1426                                  $connectionId,
1427                                  $lastmod->getTimestamp(),
1428                                  $addressobject['getetag'],
1429                                  $extradata['size'],
1430                                  $extradata['formattedname'],
1431                                  $extradata['structuredname']
1432                                 );
1433      if($res !== false)
1434        return true;
1435      $this->lastErr = "Error inserting object";
1436      return false;
1437  }
1438
1439  /**
1440   * Helper function to parse a HTTP status response into a status code only
1441   *
1442   * @param string $statusString The HTTP status string
1443   *
1444   * @return The status as a string
1445   */
1446  private function parseHttpStatus($statusString)
1447  {
1448      $status = explode(' ', $statusString, 3);
1449      $status = $status[1];
1450      return $status;
1451  }
1452
1453  /**
1454   * Helper function to remove all namespace prefixes from XML tags
1455   *
1456   * @param string $response The response to clean
1457   *
1458   * @return String containing the cleaned response
1459   */
1460  private function clean_response($response)
1461  {
1462      dbglog($response);
1463      // Gets rid of all namespace definitions
1464      $response = preg_replace('/xmlns[^=]*="[^"]*"/i', '', $response);
1465
1466      // Strip the namespace prefixes on all XML tags
1467      $response = preg_replace('/(<\/*)[^>:]+:/', '$1', $response);
1468      dbglog($response);
1469      return $response;
1470  }
1471
1472  /**
1473   * Helper function to generate a PROPFIND request
1474   *
1475   * @param array $props The properties to retrieve
1476   * @param array $ns Any custom namespaces used
1477   *
1478   * @return String containing the XML
1479   */
1480  private function buildPropfind($props, $ns = array())
1481  {
1482      $xml = new XMLWriter();
1483      $xml->openMemory();
1484      $xml->setIndent(4);
1485      $xml->startDocument('1.0', 'utf-8');
1486      $xml->startElement('D:propfind');
1487      $xml->writeAttribute('xmlns:D', 'DAV:');
1488      foreach($ns as $key => $val)
1489        $xml->writeAttribute('xmlns:'.$key, $val);
1490      $xml->startElement('D:prop');
1491        foreach($props as $prop)
1492          $xml->writeElement($prop);
1493      $xml->endElement();
1494      $xml->endElement();
1495      $xml->endDocument();
1496      return $xml->outputMemory()."\r\n";
1497  }
1498
1499  /**
1500   * Helper function to generate a REPORT
1501   *
1502   * @param string $ns The namespace
1503   * @param string $op The report operation
1504   * @param array $props (optional) The properties to retrieve
1505   * @param array $filters (optional) The filters to apply
1506   *
1507   * @return String containing the XML
1508   */
1509  private function buildReport($op, $ns = array(), $props = array(), $filters = array(), $hrefs = array())
1510  {
1511      $xml = new XMLWriter();
1512      $xml->openMemory();
1513      $xml->setIndent(4);
1514      $xml->startDocument('1.0', 'utf-8');
1515      $xml->startElement($op);
1516          $xml->writeAttribute('xmlns:D', 'DAV:');
1517          foreach($ns as $key => $val)
1518            $xml->writeAttribute('xmlns:'.$key, $val);
1519          $xml->startElement('D:prop');
1520              foreach($props as $prop)
1521              {
1522                $xml->writeElement($prop);
1523              }
1524          $xml->endElement();
1525          if(!empty($filters))
1526          {
1527              $xml->startElement('C:filter');
1528                foreach($filters as $filter => $params)
1529                {
1530                    foreach($params as $key => $val)
1531                    {
1532                        $xml->startElement($filter);
1533                        $xml->writeAttribute('name', $key);
1534                        if($val !== '')
1535                        {
1536                            $xml->startElement($filter);
1537                            $xml->writeAttribute('name', $val);
1538                            $xml->endElement();
1539                        }
1540                        $xml->endElement();
1541                    }
1542                }
1543              $xml->endElement();
1544          }
1545          foreach($hrefs as $href)
1546          {
1547              $xml->writeElement('D:href', $href);
1548          }
1549      $xml->endElement();
1550      $xml->endDocument();
1551      return $xml->outputMemory()."\r\n";
1552  }
1553
1554  /**
1555   * Synchronise all configured connections
1556   */
1557  public function syncAllConnections()
1558  {
1559      $connections = $this->getConnections();
1560      foreach($connections as $connection)
1561      {
1562          $this->syncConnection($connection['id']);
1563      }
1564  }
1565
1566  /**
1567   * Synchronise all configured connections when running with the indexer
1568   * This takes care that only *one* connection is synchronised.
1569   *
1570   * @return true if something was synchronised, otherwise false
1571   */
1572  public function indexerSyncAllConnections()
1573  {
1574      global $conf;
1575      dbglog('IndexerSyncAllConnections');
1576
1577      $connections = $this->getConnections();
1578      foreach($connections as $connection)
1579      {
1580          if($this->syncConnection($connection['id']) === true)
1581            return true;
1582      }
1583      return false;
1584  }
1585
1586  /**
1587   * Retrieve a configuration option for the plugin
1588   *
1589   * @param string $key The key to query
1590   *
1591   * @return mixed The option set, null if not found
1592   */
1593  public function getConfig($key)
1594  {
1595      return $this->getConf($key);
1596  }
1597
1598  /**
1599   * Parses some information from contact objects, used
1600   * for optimized addressbook-queries.
1601   *
1602   * @param string $contactData
1603   *
1604   * @return array
1605   */
1606  protected function getDenormalizedContactData($contactData)
1607  {
1608    require_once(DOKU_PLUGIN.'webdavclient/vendor/autoload.php');
1609
1610    $vObject = \Sabre\VObject\Reader::read($contactData);
1611    $formattedname = '';
1612    $structuredname = '';
1613
1614    if(isset($vObject->FN))
1615      $formattedname = (string)$vObject->FN;
1616
1617    if(isset($vObject->N))
1618      $structuredname = join(';', $vObject->N->getParts());
1619
1620    return array(
1621        'formattedname' => $formattedname,
1622        'structuredname' => $structuredname,
1623        'size' => strlen($contactData)
1624    );
1625  }
1626
1627  /**
1628   * Parses some information from calendar objects, used for optimized
1629   * calendar-queries.
1630   *
1631   * Returns an array with the following keys:
1632   *   * etag - An md5 checksum of the object without the quotes.
1633   *   * size - Size of the object in bytes
1634   *   * componentType - VEVENT, VTODO or VJOURNAL
1635   *   * firstOccurence
1636   *   * lastOccurence
1637   *   * uid - value of the UID property
1638   *
1639   * @param string $calendarData
1640   * @return array
1641   */
1642  protected function getDenormalizedCalendarData($calendarData)
1643  {
1644    require_once(DOKU_PLUGIN.'webdavclient/vendor/autoload.php');
1645
1646    $vObject = \Sabre\VObject\Reader::read($calendarData);
1647    $componentType = null;
1648    $component = null;
1649    $firstOccurence = null;
1650    $lastOccurence = null;
1651    $uid = null;
1652    foreach ($vObject->getComponents() as $component)
1653    {
1654        if ($component->name !== 'VTIMEZONE')
1655        {
1656            $componentType = $component->name;
1657            $uid = (string)$component->UID;
1658            break;
1659        }
1660    }
1661    if (!$componentType)
1662    {
1663        return false;
1664    }
1665    if ($componentType === 'VEVENT')
1666    {
1667        $firstOccurence = $component->DTSTART->getDateTime()->getTimeStamp();
1668        // Finding the last occurence is a bit harder
1669        if (!isset($component->RRULE))
1670        {
1671            if (isset($component->DTEND))
1672            {
1673                $lastOccurence = $component->DTEND->getDateTime()->getTimeStamp();
1674            }
1675            elseif (isset($component->DURATION))
1676            {
1677                $endDate = clone $component->DTSTART->getDateTime();
1678                $endDate->add(\Sabre\VObject\DateTimeParser::parse($component->DURATION->getValue()));
1679                $lastOccurence = $endDate->getTimeStamp();
1680            }
1681            elseif (!$component->DTSTART->hasTime())
1682            {
1683                $endDate = clone $component->DTSTART->getDateTime();
1684                $endDate->modify('+1 day');
1685                $lastOccurence = $endDate->getTimeStamp();
1686            }
1687            else
1688            {
1689                $lastOccurence = $firstOccurence;
1690            }
1691        }
1692        else
1693        {
1694            $it = new \Sabre\VObject\Recur\EventIterator($vObject, (string)$component->UID);
1695            $maxDate = new \DateTime('2038-01-01');
1696            if ($it->isInfinite())
1697            {
1698                $lastOccurence = $maxDate->getTimeStamp();
1699            }
1700            else
1701            {
1702                $end = $it->getDtEnd();
1703                while ($it->valid() && $end < $maxDate)
1704                {
1705                    $end = $it->getDtEnd();
1706                    $it->next();
1707                }
1708                $lastOccurence = $end->getTimeStamp();
1709            }
1710        }
1711    }
1712
1713    return array(
1714        'etag'           => md5($calendarData),
1715        'size'           => strlen($calendarData),
1716        'componentType'  => $componentType,
1717        'firstOccurence' => $firstOccurence,
1718        'lastOccurence'  => $lastOccurence,
1719        'uid'            => $uid,
1720    );
1721
1722  }
1723
1724/**
1725 * Retrieve the path to the sync change file for a given connection ID.
1726 * You can use this file to easily monitor if a sync has changed anything.
1727 *
1728 * @param int $connectionID The connection ID to work with
1729 * @return string The path to the sync change file
1730 */
1731  public function getLastSyncChangeFileForConnection($connectionId)
1732  {
1733      return $this->syncChangeLogFile.$connectionId;
1734  }
1735
1736}
1737