0011050: VEVENT converter: fix timezone handling for all day events
[tine20] / tine20 / Calendar / Convert / Event / VCalendar / Abstract.php
1 <?php
2 /**
3  * Tine 2.0
4  *
5  * @package     Calendar
6  * @subpackage  Frontend
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @author      Lars Kneschke <l.kneschke@metaways.de>
9  * @copyright   Copyright (c) 2011-2014 Metaways Infosystems GmbH (http://www.metaways.de)
10  *
11  */
12
13 /**
14  * abstract class to convert a single event (repeating with exceptions) to/from VCalendar
15  *
16  * @package     Calendar
17  * @subpackage  Convert
18  */
19 class Calendar_Convert_Event_VCalendar_Abstract extends Tinebase_Convert_VCalendar_Abstract implements Tinebase_Convert_Interface
20 {
21     public static $cutypeMap = array(
22         Calendar_Model_Attender::USERTYPE_USER          => 'INDIVIDUAL',
23         Calendar_Model_Attender::USERTYPE_GROUPMEMBER   => 'INDIVIDUAL',
24         Calendar_Model_Attender::USERTYPE_GROUP         => 'GROUP',
25         Calendar_Model_Attender::USERTYPE_RESOURCE      => 'RESOURCE',
26     );
27     
28     protected $_modelName = 'Calendar_Model_Event';
29
30     /**
31      * value of METHOD property
32      * @var string
33      */
34     protected $_method;
35     
36     /**
37      * options array
38      * @var array
39      * 
40      * TODO allow more options
41      * 
42      * current options:
43      *  - onlyBasicData (only use basic event data when converting from VCALENDAR to Tine 2.0)
44      */
45     protected $_options = array();
46     
47     /**
48      * set options
49      * @param array $options
50      */
51     public function setOptions($options)
52     {
53         $this->_options = $options;
54     }
55     
56     /**
57      * convert Tinebase_Record_RecordSet to Sabre\VObject\Component
58      *
59      * @param  Tinebase_Record_RecordSet  $_records
60      * @return Sabre\VObject\Component
61      */
62     public function fromTine20RecordSet(Tinebase_Record_RecordSet $_records)
63     {
64         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) 
65             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' Events: ' . print_r($_records->toArray(), true));
66         
67         // required vcalendar fields
68         $version = Tinebase_Application::getInstance()->getApplicationByName('Calendar')->version;
69         
70         $vcalendar = new \Sabre\VObject\Component\VCalendar(array(
71             'PRODID'   => "-//tine20.com//Tine 2.0 Calendar V$version//EN",
72             'VERSION'  => '2.0',
73             'CALSCALE' => 'GREGORIAN'
74         ));
75         
76         if (isset($this->_method)) {
77             $vcalendar->add('METHOD', $this->_method);
78         }
79         
80         $originatorTz = $_records->getFirstRecord() ? $_records->getFirstRecord()->originator_tz : NULL;
81         if (empty($originatorTz)) {
82             throw new Tinebase_Exception_Record_Validation('originator_tz needed for conversion to Sabre\VObject\Component');
83         }
84         
85         $vcalendar->add(new Sabre_VObject_Component_VTimezone($originatorTz));
86         
87         foreach ($_records as $_record) {
88             $this->_convertCalendarModelEvent($vcalendar, $_record);
89             
90             if ($_record->exdate instanceof Tinebase_Record_RecordSet) {
91                 $_record->exdate->addIndices(array('is_deleted'));
92                 $eventExceptions = $_record->exdate->filter('is_deleted', false);
93                 
94                 foreach ($eventExceptions as $eventException) {
95                     $this->_convertCalendarModelEvent($vcalendar, $eventException, $_record);
96                 }
97                 
98             }
99         }
100         
101         $this->_afterFromTine20Model($vcalendar);
102         
103         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) 
104             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' card ' . $vcalendar->serialize());
105         
106         return $vcalendar;
107     }
108
109     /**
110      * convert Calendar_Model_Event to Sabre\VObject\Component
111      *
112      * @param  Calendar_Model_Event  $_record
113      * @return Sabre\VObject\Component
114      */
115     public function fromTine20Model(Tinebase_Record_Abstract $_record)
116     {
117         $_records = new Tinebase_Record_RecordSet(get_class($_record), array($_record), true, false);
118         
119         return $this->fromTine20RecordSet($_records);
120     }
121     
122     /**
123      * convert calendar event to Sabre\VObject\Component
124      * 
125      * @param  \Sabre\VObject\Component\VCalendar $vcalendar
126      * @param  Calendar_Model_Event               $_event
127      * @param  Calendar_Model_Event               $_mainEvent
128      */
129     protected function _convertCalendarModelEvent(\Sabre\VObject\Component\VCalendar $vcalendar, Calendar_Model_Event $_event, Calendar_Model_Event $_mainEvent = null)
130     {
131         // clone the event and change the timezone
132         $event = clone $_event;
133         $event->setTimezone($event->originator_tz);
134         
135         $lastModifiedDateTime = $_event->last_modified_time ? $_event->last_modified_time : $_event->creation_time;
136         if (! $event->creation_time instanceof Tinebase_DateTime) {
137             throw new Tinebase_Exception_Record_Validation('creation_time needed for conversion to Sabre\VObject\Component');
138         }
139         
140         $vevent = $vcalendar->create('VEVENT', array(
141             'CREATED'       => $_event->creation_time->getClone()->setTimezone('UTC'),
142             'LAST-MODIFIED' => $lastModifiedDateTime->getClone()->setTimezone('UTC'),
143             'DTSTAMP'       => Tinebase_DateTime::now(),
144             'UID'           => $event->uid,
145         ));
146         
147         $vevent->add('SEQUENCE', $event->hasExternalOrganizer() ? $event->external_seq : $event->seq);
148         
149         if ($event->isRecurException()) {
150             $originalDtStart = $_event->getOriginalDtStart()->setTimezone($_event->originator_tz);
151             
152             $recurrenceId = $vevent->add('RECURRENCE-ID', $originalDtStart);
153             
154             if ($_mainEvent && $_mainEvent->is_all_day_event == true) {
155                 $recurrenceId['VALUE'] = 'DATE';
156             }
157         }
158         
159         // dtstart and dtend
160         $dtstart = $vevent->add('DTSTART', $_event->dtstart->getClone()->setTimezone($event->originator_tz));
161         
162         if ($event->is_all_day_event == true) {
163             $dtstart['VALUE'] = 'DATE';
164             
165             // whole day events ends at 23:59:(00|59) in Tine 2.0 but 00:00 the next day in vcalendar
166             $event->dtend->addSecond($event->dtend->get('s') == 59 ? 1 : 0);
167             $event->dtend->addMinute($event->dtend->get('i') == 59 ? 1 : 0);
168             
169             $dtend = $vevent->add('DTEND', $event->dtend);
170             $dtend['VALUE'] = 'DATE';
171         } else {
172             $dtend = $vevent->add('DTEND', $event->dtend);
173         }
174         
175         // auto status for deleted events
176         if ($event->is_deleted) {
177             $event->status = Calendar_Model_Event::STATUS_CANCELED;
178         }
179         
180         // event organizer
181         if (!empty($event->organizer)) {
182             $organizerContact = $event->resolveOrganizer();
183
184             if ($organizerContact instanceof Addressbook_Model_Contact && !empty($organizerContact->email)) {
185                 $organizer = $vevent->add(
186                     'ORGANIZER',
187                     'mailto:' . $organizerContact->email,
188                     array('CN' => $organizerContact->n_fileas, 'EMAIL' => $organizerContact->email)
189                 );
190             }
191         }
192
193         $this->_addEventAttendee($vevent, $event);
194         
195         $optionalProperties = array(
196             'class',
197             'status',
198             'description',
199             'geo',
200             'location',
201             'priority',
202             'summary',
203             'transp',
204             'url'
205         );
206         
207         foreach ($optionalProperties as $property) {
208             if (!empty($event->$property)) {
209                 $vevent->add(strtoupper($property), $event->$property);
210             }
211         }
212
213         $class = $event->class == Calendar_Model_Event::CLASS_PUBLIC ? 'PUBLIC' : 'CONFIDENTIAL';
214         $vcalendar->add('X-CALENDARSERVER-ACCESS', $class);
215         $vevent->add('X-CALENDARSERVER-ACCESS', $class);
216
217         // categories
218         if (!isset($event->tags)) {
219             $event->tags = Tinebase_Tags::getInstance()->getTagsOfRecord($event);
220         }
221         if(isset($event->tags) && count($event->tags) > 0) {
222             $vevent->add('CATEGORIES', (array) $event->tags->name);
223         }
224         
225         // repeating event properties
226         if ($event->rrule) {
227             if ($event->is_all_day_event == true) {
228                 $vevent->add('RRULE', preg_replace_callback('/UNTIL=([\d :-]{19})(?=;?)/', function($matches) {
229                     $dtUntil = new Tinebase_DateTime($matches[1]);
230                     $dtUntil->setTimezone((string) Tinebase_Core::get(Tinebase_Core::USERTIMEZONE));
231                     
232                     return 'UNTIL=' . $dtUntil->format('Ymd');
233                 }, $event->rrule));
234             } else {
235                 $vevent->add('RRULE', preg_replace('/(UNTIL=)(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/', '$1$2$3$4T$5$6$7Z', $event->rrule));
236             }
237             
238             if ($event->exdate instanceof Tinebase_Record_RecordSet) {
239                 $event->exdate->addIndices(array('is_deleted'));
240                 $deletedEvents = $event->exdate->filter('is_deleted', true);
241                 
242                 foreach ($deletedEvents as $deletedEvent) {
243                     $dateTime = $deletedEvent->getOriginalDtStart();
244
245                     $exdate = $vevent->add('EXDATE');
246                     
247                     if ($event->is_all_day_event == true) {
248                         $dateTime->setTimezone($event->originator_tz);
249                         $exdate['VALUE'] = 'DATE';
250                     }
251                     
252                     $exdate->setValue($dateTime);
253                 }
254             }
255         }
256         
257         $ownAttendee = Calendar_Model_Attender::getOwnAttender($event->attendee);
258         
259         if ($event->alarms instanceof Tinebase_Record_RecordSet) {
260             $mozLastAck = NULL;
261             $mozSnooze = NULL;
262             
263             foreach ($event->alarms as $alarm) {
264                 $valarm = $vcalendar->create('VALARM');
265                 $valarm->add('ACTION', 'DISPLAY');
266                 $valarm->add('DESCRIPTION', $event->summary);
267                 
268                 if ($dtack = Calendar_Controller_Alarm::getAcknowledgeTime($alarm)) {
269                     $valarm->add('ACKNOWLEDGED', $dtack->getClone()->setTimezone('UTC')->format('Ymd\\THis\\Z'));
270                     $mozLastAck = $dtack > $mozLastAck ? $dtack : $mozLastAck;
271                 }
272                 
273                 if ($dtsnooze = Calendar_Controller_Alarm::getSnoozeTime($alarm)) {
274                     $mozSnooze = $dtsnooze > $mozSnooze ? $dtsnooze : $mozSnooze;
275                 }
276                 if (is_numeric($alarm->minutes_before)) {
277                     if ($event->dtstart == $alarm->alarm_time) {
278                         $periodString = 'PT0S';
279                     } else {
280                         $interval = $event->dtstart->diff($alarm->alarm_time);
281                         $periodString = sprintf('%sP%s%s%s%s',
282                             $interval->format('%r'),
283                             $interval->format('%d') > 0 ? $interval->format('%dD') : null,
284                             ($interval->format('%h') > 0 || $interval->format('%i') > 0) ? 'T' : null,
285                             $interval->format('%h') > 0 ? $interval->format('%hH') : null,
286                             $interval->format('%i') > 0 ? $interval->format('%iM') : null
287                         );
288                     }
289                     # TRIGGER;VALUE=DURATION:-PT1H15M
290                     $trigger = $valarm->add('TRIGGER', $periodString);
291                     $trigger['VALUE'] = "DURATION";
292                 } else {
293                     # TRIGGER;VALUE=DATE-TIME:...
294                     $trigger = $valarm->add('TRIGGER', $alarm->alarm_time->getClone()->setTimezone('UTC')->format('Ymd\\THis\\Z'));
295                     $trigger['VALUE'] = "DATE-TIME";
296                 }
297                 
298                 $vevent->add($valarm);
299             }
300             
301             if ($mozLastAck instanceof DateTime) {
302                 $vevent->add('X-MOZ-LASTACK', $mozLastAck->getClone()->setTimezone('UTC'), array('VALUE' => 'DATE-TIME'));
303             }
304             
305             if ($mozSnooze instanceof DateTime) {
306                 $vevent->add('X-MOZ-SNOOZE-TIME', $mozSnooze->getClone()->setTimezone('UTC'), array('VALUE' => 'DATE-TIME'));
307             }
308         }
309         
310         $baseUrl = Tinebase_Core::getHostname() . "/webdav/Calendar/records/Calendar_Model_Event/{$event->getId()}/";
311         
312         if ($event->attachments instanceof Tinebase_Record_RecordSet) {
313             foreach ($event->attachments as $attachment) {
314                 $filename = rawurlencode($attachment->name);
315                 $attach = $vcalendar->createProperty('ATTACH', "{$baseUrl}{$filename}", array(
316                     'MANAGED-ID' => $attachment->hash,
317                     'FMTTYPE'    => $attachment->contenttype,
318                     'SIZE'       => $attachment->size,
319                     'FILENAME'   => $filename
320                 ), 'TEXT');
321                 
322                 $vevent->add($attach);
323             }
324             
325         }
326         
327         $vcalendar->add($vevent);
328     }
329     
330     /**
331      * add event attendee to VEVENT object 
332      * 
333      * @param \Sabre\VObject\Component\VEvent $vevent
334      * @param Calendar_Model_Event            $event
335      */
336     protected function _addEventAttendee(\Sabre\VObject\Component\VEvent $vevent, Calendar_Model_Event $event)
337     {
338         if (empty($event->attendee)) {
339             return;
340         }
341         
342         Calendar_Model_Attender::resolveAttendee($event->attendee, FALSE, $event);
343         
344         foreach($event->attendee as $eventAttendee) {
345             $attendeeEmail = $eventAttendee->getEmail();
346             
347             $parameters = array(
348                 'CN'       => $eventAttendee->getName(),
349                 'CUTYPE'   => Calendar_Convert_Event_VCalendar_Abstract::$cutypeMap[$eventAttendee->user_type],
350                 'PARTSTAT' => $eventAttendee->status,
351                 'ROLE'     => "{$eventAttendee->role}-PARTICIPANT",
352                 'RSVP'     => 'FALSE'
353             );
354             if (strpos($attendeeEmail, '@') !== false) {
355                 $parameters['EMAIL'] = $attendeeEmail;
356             }
357             $vevent->add('ATTENDEE', (strpos($attendeeEmail, '@') !== false ? 'mailto:' : 'urn:uuid:') . $attendeeEmail, $parameters);
358         }
359     }
360
361     /**
362      * set the METHOD for the generated VCALENDAR
363      *
364      * @param  string  $_method  the method
365      */
366     public function setMethod($method)
367     {
368         $this->_method = $method;
369     }
370     
371     /**
372      * converts vcalendar to Calendar_Model_Event
373      * 
374      * @param  mixed                 $_blob    the VCALENDAR to parse
375      * @param  Calendar_Model_Event  $_record  update existing event
376      * @param  array                 $options  array of options
377      * @return Calendar_Model_Event
378      */
379     public function toTine20Model($blob, Tinebase_Record_Abstract $_record = null, $options = array())
380     {
381         $vcalendar = self::getVObject($blob);
382         
383         // contains the VCALENDAR any VEVENTS
384         if (! isset($vcalendar->VEVENT)) {
385             throw new Tinebase_Exception_UnexpectedValue('no vevents found');
386         }
387         
388         // update a provided record or create a new one
389         if ($_record instanceof Calendar_Model_Event) {
390             $event = $_record;
391         } else {
392             $event = new Calendar_Model_Event(null, false);
393         }
394         
395         if (isset($vcalendar->METHOD)) {
396             $this->setMethod($vcalendar->METHOD);
397         }
398         
399         $baseVevent = $this->_findMainEvent($vcalendar);
400         
401         if (! $baseVevent) {
402             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
403                 . ' No main VEVENT found');
404             
405             if (! $_record && count($vcalendar->VEVENT) > 0) {
406                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
407                     . ' Convert recur exception without existing event using first VEVENT');
408                 $this->_convertVevent($vcalendar->VEVENT[0], $event, $options);
409             }
410         } else {
411             $this->_convertVevent($baseVevent, $event, $options);
412         }
413         
414         $this->_parseEventExceptions($event, $vcalendar, $baseVevent, $options);
415         
416         $event->isValid(true);
417         
418         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ 
419             . ' Event: ' . print_r($event->toArray(), true));
420         
421         return $event;
422     }
423     
424     /**
425      * find the main event - the main event has no RECURRENCE-ID
426      * 
427      * @param \Sabre\VObject\Component\VCalendar $vcalendar
428      * @return \Sabre\VObject\Component\VCalendar | null
429      */
430     protected function _findMainEvent(\Sabre\VObject\Component\VCalendar $vcalendar)
431     {
432         foreach ($vcalendar->VEVENT as $vevent) {
433             if (! isset($vevent->{'RECURRENCE-ID'})) {
434                 return $vevent;
435             }
436         }
437         
438         return null;
439     }
440     
441     /**
442      * parse event exceptions and add them to Tine 2.0 event record
443      * 
444      * @param  Calendar_Model_Event                $event
445      * @param  \Sabre\VObject\Component\VCalendar  $vcalendar
446      * @param  \Sabre\VObject\Component\VCalendar  $baseVevent
447      * @param  array                               $options
448      */
449     protected function _parseEventExceptions(Calendar_Model_Event $event, \Sabre\VObject\Component\VCalendar $vcalendar, $baseVevent = null, $options = array())
450     {
451         if (! $event->exdate instanceof Tinebase_Record_RecordSet) {
452             $event->exdate = new Tinebase_Record_RecordSet('Calendar_Model_Event');
453         }
454         $recurExceptions = $event->exdate->filter('is_deleted', false);
455         
456         foreach ($vcalendar->VEVENT as $vevent) {
457             if (isset($vevent->{'RECURRENCE-ID'}) && $event->uid == $vevent->UID) {
458                 $recurException = $this->_getRecurException($recurExceptions, $vevent);
459                 
460                 // initialize attendee with attendee from base events for new exceptions
461                 // this way we can keep attendee extra values like groupmember type
462                 // attendees which do not attend to the new exception will be removed in _convertVevent
463                 if (! $recurException->attendee instanceof Tinebase_Record_RecordSet) {
464                     $recurException->attendee = new Tinebase_Record_RecordSet('Calendar_Model_Attender');
465                     foreach ($event->attendee as $attendee) {
466                         $recurException->attendee->addRecord(new Calendar_Model_Attender(array(
467                             'user_id'   => $attendee->user_id,
468                             'user_type' => $attendee->user_type,
469                             'role'      => $attendee->role,
470                             'status'    => $attendee->status
471                         )));
472                     }
473                 }
474                 
475                 // initialize attachments from base event as clients may skip parameters like
476                 // name and content type and we can't backward relove them from managedId
477                 if ($event->attachments instanceof Tinebase_Record_RecordSet && 
478                         ! $recurException->attachments instanceof Tinebase_Record_RecordSet) {
479                     $recurException->attachments = new Tinebase_Record_RecordSet('Tinebase_Model_Tree_Node');
480                     foreach ($event->attachments as $attachment) {
481                         $recurException->attachments->addRecord(new Tinebase_Model_Tree_Node(array(
482                             'name'         => $attachment->name,
483                             'type'         => Tinebase_Model_Tree_Node::TYPE_FILE,
484                             'contenttype'  => $attachment->contenttype,
485                             'hash'         => $attachment->hash,
486                         ), true));
487                     }
488                 }
489                 
490                 if ($baseVevent) {
491                     $this->_adaptBaseEventProperties($vevent, $baseVevent);
492                 }
493                 
494                 $this->_convertVevent($vevent, $recurException, $options);
495                 
496                 if (! $recurException->getId()) {
497                     $event->exdate->addRecord($recurException);
498                 }
499             }
500         }
501     }
502     
503     /**
504      * adapt X-MOZ-LASTACK / X-MOZ-SNOOZE-TIME from base vevent
505      * 
506      * @see 0009396: alarm_ack_time and alarm_snooze_time are not updated
507      */
508     protected function _adaptBaseEventProperties($vevent, $baseVevent)
509     {
510         $propertiesToAdapt = array('X-MOZ-LASTACK', 'X-MOZ-SNOOZE-TIME');
511         
512         foreach ($propertiesToAdapt as $property) {
513             if (isset($baseVevent->{$property})) {
514                 $vevent->{$property} = $baseVevent->{$property};
515             }
516         }
517     }
518     
519     /**
520      * template method
521      * 
522      * implement if client has support for sending attachments
523      * 
524      * @param Calendar_Model_Event          $event
525      * @param Tinebase_Record_RecordSet     $attachments
526      */
527     protected function _manageAttachmentsFromClient($event, $attachments) {}
528     
529     /**
530      * convert VCALENDAR to Tinebase_Record_RecordSet of Calendar_Model_Event
531      * 
532      * @param  mixed  $blob  the vcalendar to parse
533      * @param  array  $options
534      * @return Tinebase_Record_RecordSet
535      */
536     public function toTine20RecordSet($blob, $options = array())
537     {
538         $vcalendar = self::getVObject($blob);
539         
540         $result = new Tinebase_Record_RecordSet('Calendar_Model_Event');
541         
542         foreach ($vcalendar->VEVENT as $vevent) {
543             if (! isset($vevent->{'RECURRENCE-ID'})) {
544                 $event = new Calendar_Model_Event();
545                 $this->_convertVevent($vevent, $event, $options);
546                 if (! empty($event->rrule)) {
547                     $this->_parseEventExceptions($event, $vcalendar, $options);
548                 }
549                 $result->addRecord($event);
550             }
551         }
552         
553         return $result;
554     }
555     
556     /**
557      * get METHOD of current VCALENDAR or supplied blob
558      * 
559      * @param  string  $blob
560      * @return string|NULL
561      */
562     public function getMethod($blob = NULL)
563     {
564         if ($this->_method) {
565             return $this->_method;
566         }
567         
568         if ($blob !== NULL) {
569             $vcalendar = self::getVObject($blob);
570             return $vcalendar->METHOD;
571         }
572         
573         return null;
574     }
575
576     /**
577      * find a matching exdate or return an empty event record
578      * 
579      * @param  Tinebase_Record_RecordSet        $oldExdates
580      * @param  \Sabre\VObject\Component\VEvent  $vevent
581      * @return Calendar_Model_Event
582      */
583     protected function _getRecurException(Tinebase_Record_RecordSet $oldExdates,Sabre\VObject\Component\VEvent $vevent)
584     {
585         // we need to use the user timezone here if this is a DATE (like this: RECURRENCE-ID;VALUE=DATE:20140429)
586         $exDate = Calendar_Convert_Event_VCalendar_Abstract::getUTCDateFromStringInUsertime((string) $vevent->{'RECURRENCE-ID'});
587         $exDateString = $exDate->format('Y-m-d H:i:s');
588
589         foreach ($oldExdates as $id => $oldExdate) {
590             if ($exDateString == substr((string) $oldExdate->recurid, -19)) {
591                 return $oldExdate;
592             }
593         }
594         
595         return new Calendar_Model_Event();
596     }
597     
598     /**
599      * get attendee array for given contact
600      * 
601      * @param  \Sabre\VObject\Property\ICalendar\CalAddress  $calAddress  the attendee row from the vevent object
602      * @return array
603      */
604     protected function _getAttendee(\Sabre\VObject\Property\ICalendar\CalAddress $calAddress)
605     {
606         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) 
607             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' attendee ' . $calAddress->serialize());
608         
609         if (isset($calAddress['CUTYPE']) && in_array($calAddress['CUTYPE']->getValue(), array('INDIVIDUAL', Calendar_Model_Attender::USERTYPE_GROUP, Calendar_Model_Attender::USERTYPE_RESOURCE))) {
610             $type = $calAddress['CUTYPE']->getValue() == 'INDIVIDUAL' ? Calendar_Model_Attender::USERTYPE_USER : $calAddress['CUTYPE']->getValue();
611         } else {
612             $type = Calendar_Model_Attender::USERTYPE_USER;
613         }
614         
615         if (isset($calAddress['ROLE']) && in_array($calAddress['ROLE']->getValue(), array(Calendar_Model_Attender::ROLE_OPTIONAL, Calendar_Model_Attender::ROLE_REQUIRED))) {
616             $role = $calAddress['ROLE']->getValue();
617         } else {
618             $role = Calendar_Model_Attender::ROLE_REQUIRED;
619         }
620         
621         if (isset($calAddress['PARTSTAT']) && in_array($calAddress['PARTSTAT']->getValue(), array(
622             Calendar_Model_Attender::STATUS_ACCEPTED,
623             Calendar_Model_Attender::STATUS_DECLINED,
624             Calendar_Model_Attender::STATUS_NEEDSACTION,
625             Calendar_Model_Attender::STATUS_TENTATIVE
626         ))) {
627             $status = $calAddress['PARTSTAT']->getValue();
628         } else {
629             $status = Calendar_Model_Attender::STATUS_NEEDSACTION;
630         }
631         
632         if (!empty($calAddress['EMAIL'])) {
633             $email = $calAddress['EMAIL']->getValue();
634         } else {
635             if (! preg_match('/(?P<protocol>mailto:|urn:uuid:)(?P<email>.*)/i', $calAddress->getValue(), $matches)) {
636                 if (preg_match(Tinebase_Mail::EMAIL_ADDRESS_REGEXP, $calAddress->getValue())) {
637                     $email = $calAddress->getValue();
638                 } else {
639                     if (Tinebase_Core::isLogLevel(Zend_Log::WARN)) 
640                         Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' invalid attendee provided: ' . $calAddress->getValue());
641                     return null;
642                 }
643             } else {
644                 $email = $matches['email'];
645             }
646         }
647         
648         $fullName = isset($calAddress['CN']) ? $calAddress['CN']->getValue() : $email;
649         
650         if (preg_match('/(?P<firstName>\S*) (?P<lastNameName>\S*)/', $fullName, $matches)) {
651             $firstName = $matches['firstName'];
652             $lastName  = $matches['lastNameName'];
653         } else {
654             $firstName = null;
655             $lastName  = $fullName;
656         }
657
658         $attendee = array(
659             'userType'  => $type,
660             'firstName' => $firstName,
661             'lastName'  => $lastName,
662             'partStat'  => $status,
663             'role'      => $role,
664             'email'     => $email
665         );
666         
667         return $attendee;
668     }
669     
670     /**
671      * parse VEVENT part of VCALENDAR
672      * 
673      * @param  \Sabre\VObject\Component\VEvent  $vevent  the VEVENT to parse
674      * @param  Calendar_Model_Event             $event   the Tine 2.0 event to update
675      * @param  array                            $options
676      */
677     protected function _convertVevent(\Sabre\VObject\Component\VEvent $vevent, Calendar_Model_Event $event, $options)
678     {
679         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE))
680             Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__ . ' vevent ' . $vevent->serialize());
681         
682         $newAttendees = array();
683         $shortenedFields = array();
684         $attachments = new Tinebase_Record_RecordSet('Tinebase_Model_Tree_Node');
685         $event->alarms = new Tinebase_Record_RecordSet('Tinebase_Model_Alarm');
686         $skipFieldsIfOnlyBasicData = array('ATTENDEE', 'UID', 'ORGANIZER', 'VALARM', 'ATTACH', 'CATEGORIES');
687         
688         foreach ($vevent->children() as $property) {
689             if (isset($this->_options['onlyBasicData'])
690                 && $this->_options['onlyBasicData']
691                 && in_array((string) $property->name, $skipFieldsIfOnlyBasicData))
692             {
693                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
694                     Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' Skipping '
695                             . $property->name . ' (using option onlyBasicData)');
696                 continue;
697             }
698             
699             switch ($property->name) {
700                 case 'CREATED':
701                 case 'DTSTAMP':
702                     if (! isset($options[self::OPTION_USE_SERVER_MODLOG]) || $options[self::OPTION_USE_SERVER_MODLOG] !== true) {
703                         $event->{$property->name == 'CREATED' ? 'creation_time' : 'last_modified_time'} = $this->_convertToTinebaseDateTime($property);
704                     }
705                     break;
706                     
707                 case 'LAST-MODIFIED':
708                     $event->last_modified_time = new Tinebase_DateTime($property->getValue());
709                     break;
710                 
711                 case 'ATTENDEE':
712                     $newAttendee = $this->_getAttendee($property);
713                     if ($newAttendee) {
714                         $newAttendees[] = $newAttendee;
715                     }
716                     break;
717                     
718                 case 'CLASS':
719                     if (in_array($property->getValue(), array(Calendar_Model_Event::CLASS_PRIVATE, Calendar_Model_Event::CLASS_PUBLIC))) {
720                         $event->class = $property->getValue();
721                     } else {
722                         $event->class = Calendar_Model_Event::CLASS_PUBLIC;
723                     }
724                     
725                     break;
726
727                 case 'STATUS':
728                     if (in_array($property->getValue(), array(Calendar_Model_Event::STATUS_CONFIRMED, Calendar_Model_Event::STATUS_TENTATIVE, Calendar_Model_Event::STATUS_CANCELED))) {
729                         $event->status = $property->getValue();
730                     } else {
731                         $event->status = Calendar_Model_Event::STATUS_CONFIRMED;
732                     }
733                     break;
734                     
735                 case 'DTEND':
736                     
737                     if (isset($property['VALUE']) && strtoupper($property['VALUE']) == 'DATE') {
738                         // all day event
739                         $event->is_all_day_event = true;
740                         $dtend = $this->_convertToTinebaseDateTime($property, TRUE);
741                         
742                         // whole day events ends at 23:59:59 in Tine 2.0 but 00:00 the next day in vcalendar
743                         $dtend->subSecond(1);
744                     } else {
745                         $event->is_all_day_event = false;
746                         $dtend = $this->_convertToTinebaseDateTime($property);
747                     }
748                     
749                     $event->dtend = $dtend;
750                     
751                     break;
752                     
753                 case 'DTSTART':
754                     if (isset($property['VALUE']) && strtoupper($property['VALUE']) == 'DATE') {
755                         // all day event
756                         $event->is_all_day_event = true;
757                         $dtstart = $this->_convertToTinebaseDateTime($property, TRUE);
758                     } else {
759                         $event->is_all_day_event = false;
760                         $dtstart = $this->_convertToTinebaseDateTime($property);
761                     }
762                     
763                     $event->originator_tz = $dtstart->getTimezone()->getName();
764                     $event->dtstart = $dtstart;
765                     
766                     break;
767                     
768                 case 'SEQUENCE':
769                     if (! isset($options[self::OPTION_USE_SERVER_MODLOG]) || $options[self::OPTION_USE_SERVER_MODLOG] !== true) {
770                         $event->seq = $property->getValue();
771                     }
772                     // iMIP only
773                     $event->external_seq = $property->getValue();
774                     break;
775                     
776                 case 'DESCRIPTION':
777                 case 'LOCATION':
778                 case 'SUMMARY':
779                     $key = strtolower($property->name);
780                     $value = $property->getValue();
781                     switch ($key) {
782                         case 'summary':
783                         case 'location':
784                             if (strlen($value) > 255) {
785                                 $shortenedFields[$key] = $value;
786                                 $endPos = strpos($value, "\n") !== false && strpos($value, "\n") < 255 
787                                     ? strpos($value, "\n") 
788                                     : 255;
789                                 if (extension_loaded('mbstring')) {
790                                     $value = mb_substr($value, 0, $endPos, 'UTF-8');
791                                 } else {
792                                     $value = Tinebase_Core::filterInputForDatabase(substr($value, 0, $endPos));
793                                 }
794                             }
795                             break;
796                     }
797                     $event->$key = $value;
798                     
799                     break;
800                     
801                 case 'ORGANIZER':
802                     $email = null;
803                     
804                     if (!empty($property['EMAIL'])) {
805                         $email = $property['EMAIL'];
806                     } elseif (preg_match('/mailto:(?P<email>.*)/i', $property->getValue(), $matches)) {
807                         $email = $matches['email'];
808                     }
809                     
810                     if ($email !== null) {
811                         // it's not possible to change the organizer by spec
812                         if (empty($event->organizer)) {
813                             $name = isset($property['CN']) ? $property['CN']->getValue() : $email;
814                             $contact = Calendar_Model_Attender::resolveEmailToContact(array(
815                                 'email'     => $email,
816                                 'lastName'  => $name,
817                             ));
818                         
819                             $event->organizer = $contact->getId();
820                         }
821                         
822                         // Lightning attaches organizer ATTENDEE properties to ORGANIZER property and does not add an ATTENDEE for the organizer
823                         if (isset($property['PARTSTAT'])) {
824                             $newAttendees[] = $this->_getAttendee($property);
825                         }
826                     }
827                     
828                     break;
829
830                 case 'RECURRENCE-ID':
831                     // original start of the event
832                     $event->recurid = $this->_convertToTinebaseDateTime($property);
833                     
834                     // convert recurrence id to utc
835                     $event->recurid->setTimezone('UTC');
836                     
837                     break;
838                     
839                 case 'RRULE':
840                     $rruleString = $property->getValue();
841                     
842                     // convert date format
843                     $rruleString = preg_replace_callback('/UNTIL=([\dTZ]+)(?=;?)/', function($matches) {
844                         $dtUntil = Calendar_Convert_Event_VCalendar_Abstract::getUTCDateFromStringInUsertime($matches[1]);
845                         return 'UNTIL=' . $dtUntil->format(Tinebase_Record_Abstract::ISO8601LONG);
846                     }, $rruleString);
847
848                     // remove additional days from BYMONTHDAY property (BYMONTHDAY=11,15 => BYMONTHDAY=11)
849                     $rruleString = preg_replace('/(BYMONTHDAY=)([\d]+),([,\d]+)/', '$1$2', $rruleString);
850                     
851                     $event->rrule = $rruleString;
852                     
853                     // process exceptions
854                     if (isset($vevent->EXDATE)) {
855                         $exdates = new Tinebase_Record_RecordSet('Calendar_Model_Event');
856                         
857                         foreach ($vevent->EXDATE as $exdate) {
858                             foreach ($exdate->getDateTimes() as $exception) {
859                                 if (isset($exdate['VALUE']) && strtoupper($exdate['VALUE']) == 'DATE') {
860                                     $recurid = new Tinebase_DateTime($exception->format(Tinebase_Record_Abstract::ISO8601LONG), (string) Tinebase_Core::get(Tinebase_Core::USERTIMEZONE));
861                                 } else {
862                                     $recurid = new Tinebase_DateTime($exception->format(Tinebase_Record_Abstract::ISO8601LONG), $exception->getTimezone());
863                                 }
864                                 $recurid->setTimezone(new DateTimeZone('UTC'));
865                                 
866                                 $eventException = new Calendar_Model_Event(array(
867                                     'recurid'    => $recurid,
868                                     'is_deleted' => true
869                                 ));
870                         
871                                 $exdates->addRecord($eventException);
872                             }
873                         }
874                     
875                         $event->exdate = $exdates;
876                     }
877                     
878                     break;
879                     
880                 case 'TRANSP':
881                     if (in_array($property->getValue(), array(Calendar_Model_Event::TRANSP_OPAQUE, Calendar_Model_Event::TRANSP_TRANSP))) {
882                         $event->transp = $property->getValue();
883                     } else {
884                         $event->transp = Calendar_Model_Event::TRANSP_TRANSP;
885                     }
886                     
887                     break;
888                     
889                 case 'UID':
890                     // it's not possible to change the uid by spec
891                     if (!empty($event->uid)) {
892                         continue;
893                     }
894                     
895                     $event->uid = $property->getValue();
896                 
897                     break;
898                     
899                 case 'VALARM':
900                     $this->_parseAlarm($event, $property, $vevent);
901                     break;
902                     
903                 case 'CATEGORIES':
904                     $tags = Tinebase_Model_Tag::resolveTagNameToTag($property->getParts(), 'Calendar');
905                     if (! isset($event->tags)) {
906                         $event->tags = $tags;
907                     } else {
908                         $event->tags->merge($tags);
909                     }
910                     break;
911                     
912                 case 'ATTACH':
913                     $name = (string) $property['FILENAME'];
914                     $managedId = (string) $property['MANAGED-ID'];
915                     $value = (string) $property['VALUE'];
916                     $attachment = NULL;
917                     $readFromURL = false;
918                     $url = '';
919                     
920                     if (Tinebase_Core::isLogLevel(Zend_Log::INFO))
921                         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' attachment found: ' . $name . ' ' . $managedId);
922                     
923                     if ($managedId) {
924                         $attachment = $event->attachments instanceof Tinebase_Record_RecordSet ?
925                             $event->attachments->filter('hash', $property['MANAGED-ID'])->getFirstRecord() :
926                             NULL;
927                         
928                         // NOTE: we might miss a attachment here for the following reasons
929                         //       1. client reuses a managed id (we are server):
930                         //          We havn't observerd this yet. iCal client reuse manged id's
931                         //          from base events in exceptions but this is covered as we 
932                         //          initialize new exceptions with base event attachments
933                         //          
934                         //          When a client reuses a managed id it's not clear yet if
935                         //          this managed id needs to be in the same series/calendar/server
936                         //
937                         //          As we use the object hash the managed id might be used in the 
938                         //          same files with different names. We need to evaluate the name
939                         //          (if attached) in this case as well.
940                         //       
941                         //       2. server send his managed id (we are client)
942                         //          * we need to download the attachment (here?)
943                         //          * we need to have a mapping externalid / internalid (where?)
944                         
945                         if (! $attachment) {
946                             $readFromURL = true;
947                             $url = $property->getValue();
948                         } else {
949                             $attachments->addRecord($attachment);
950                         }
951                     } elseif('URI' === $value) {
952                         /*
953                          * ATTACH;VALUE=URI:https://server.com/calendars/__uids__/0AA0
954  3A3B-F7B6-459A-AB3E-4726E53637D0/dropbox/4971F93F-8657-412B-841A-A0FD913
955  9CD61.dropbox/Canada.png
956                          */
957                         $url = $property->getValue();
958                         $urlParts = parse_url($url);
959                         $host = $urlParts['host'];
960                         $name = pathinfo($urlParts['path'], PATHINFO_BASENAME);
961
962                         // iCal 10.7 places URI before uploading
963                         if (parse_url(Tinebase_Core::getHostname(), PHP_URL_HOST) != $host) {
964                             $readFromURL = true;
965                         }
966                     }
967                     // base64
968                     else {
969                         // @TODO: implement (check if add / update / update is needed)
970                         if (Tinebase_Core::isLogLevel(Zend_Log::WARN))
971                                 Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' attachment found that could not be imported due to missing managed id');
972                     }
973                     
974                     if ($readFromURL) {
975                         if (preg_match('#^(https?://)(.*)$#', str_replace(array("\n","\r"), '', $url), $matches)) {
976                             // we are client and found an external hosted attachment that we need to import
977                             $user = Tinebase_Core::getUser();
978                             $userCredentialCache = Tinebase_Core::get(Tinebase_Core::USERCREDENTIALCACHE);
979                             $url = $matches[1] . $userCredentialCache->username . ':' . $userCredentialCache->password . '@' . $matches[2];
980                             $attachmentInfo = $matches[1] . $matches[2]. ' ' . $name . ' ' . $managedId;
981                             if (urlExists($url)) {
982                                 if (Tinebase_Core::isLogLevel(Zend_Log::INFO))
983                                     Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__
984                                             . ' Downloading attachment: ' . $attachmentInfo);
985                                 
986                                 $stream = fopen($url, 'r');
987                                 $attachment = new Tinebase_Model_Tree_Node(array(
988                                     'name'         => rawurldecode($name),
989                                     'type'         => Tinebase_Model_Tree_Node::TYPE_FILE,
990                                     'contenttype'  => (string) $property['FMTTYPE'],
991                                     'tempFile'     => $stream,
992                                     ), true);
993                                 $attachments->addRecord($attachment);
994                             } else {
995                                 if (Tinebase_Core::isLogLevel(Zend_Log::WARN)) Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ 
996                                     . ' Url not found (got 404): ' . $attachmentInfo . ' - Skipping attachment');
997                             }
998                         } else {
999                             if (Tinebase_Core::isLogLevel(Zend_Log::WARN))
1000                                 Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ 
1001                                     . ' Attachment found with malformed url: ' . $url);
1002                         }
1003                     }
1004                     break;
1005                     
1006                 case 'X-MOZ-LASTACK':
1007                     $lastAck = $this->_convertToTinebaseDateTime($property);
1008                     break;
1009                     
1010                 case 'X-MOZ-SNOOZE-TIME':
1011                     $snoozeTime = $this->_convertToTinebaseDateTime($property);
1012                     break;
1013                     
1014                 default:
1015                     // thunderbird saves snooze time for recurring event occurrences in properties with names like this -
1016                     // we just assume that the event/recur series has only one snooze time 
1017                     if (preg_match('/^X-MOZ-SNOOZE-TIME-[0-9]+$/', $property->name)) {
1018                         $snoozeTime = $this->_convertToTinebaseDateTime($property);
1019                         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ 
1020                             . ' Found snooze time for recur occurrence: ' . $snoozeTime->toString());
1021                     }
1022                     break;
1023             }
1024         }
1025
1026         // NOTE: X-CALENDARSERVER-ACCESS overwrites CLASS
1027         if (isset($vevent->{'X-CALENDARSERVER-ACCESS'})) {
1028             $event->class = $vevent->{'X-CALENDARSERVER-ACCESS'} == 'PUBLIC' ?
1029                 Calendar_Model_Event::CLASS_PUBLIC :
1030                 Calendar_Model_Event::CLASS_PRIVATE;
1031         }
1032
1033         foreach ($shortenedFields as $key => $value) {
1034             $event->description = "--------\n$key: $value";
1035         }
1036         
1037         if (isset($lastAck)) {
1038             Calendar_Controller_Alarm::setAcknowledgeTime($event->alarms, $lastAck);
1039         }
1040         if (isset($snoozeTime)) {
1041             Calendar_Controller_Alarm::setSnoozeTime($event->alarms, $snoozeTime);
1042         }
1043         
1044         // merge old and new attendee
1045         Calendar_Model_Attender::emailsToAttendee($event, $newAttendees);
1046         
1047         if (empty($event->seq)) {
1048             $event->seq = 1;
1049         }
1050         
1051         if (empty($event->class)) {
1052             $event->class = Calendar_Model_Event::CLASS_PUBLIC;
1053         }
1054         
1055         $this->_manageAttachmentsFromClient($event, $attachments);
1056         
1057         if (empty($event->dtend)) {
1058             // TODO find out duration (see TRIGGER DURATION)
1059 //             if (isset($vevent->DURATION)) {
1060 //             }
1061             if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
1062                     . ' Got event without dtend. Assuming 30 minutes duration');
1063             $event->dtend = clone $event->dtstart;
1064             $event->dtend->addMinute(30);
1065         }
1066         
1067         // convert all datetime fields to UTC
1068         $event->setTimezone('UTC');
1069     }
1070     
1071     /**
1072      * get utc datetime from date string and handle dates (ie 20140922) in usertime
1073      * 
1074      * @param string $dateString
1075      * 
1076      * TODO maybe this can be replaced with _convertToTinebaseDateTime
1077      */
1078     static public function getUTCDateFromStringInUsertime($dateString)
1079     {
1080         if (strlen($dateString) < 10) {
1081             $date = date_create($dateString, new DateTimeZone ((string) Tinebase_Core::get(Tinebase_Core::USERTIMEZONE)));
1082         } else {
1083             $date = date_create($dateString);
1084         }
1085         $date->setTimezone(new DateTimeZone('UTC'));
1086         return $date;
1087     }
1088 }