0010080: caldav client / import caldav tasks/task lists via CLI
[tine20] / tine20 / Tasks / Frontend / WebDAV / Task.php
1 <?php
2 /**
3  * Tine 2.0
4  *
5  * @package     Tasks
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-2013 Metaways Infosystems GmbH (http://www.metaways.de)
10  *
11  */
12
13 /**
14  * class to handle a single task
15  *
16  * This class handles the creation, update and deletion of vevents
17  *
18  * @package     Tasks
19  * @subpackage  Frontend
20  */
21 class Tasks_Frontend_WebDAV_Task extends Sabre\DAV\File implements Sabre\CalDAV\ICalendarObject, Sabre\DAVACL\IACL
22 {
23     /**
24      * @var Tinebase_Model_Container
25      */
26     protected $_container;
27     
28     /**
29      * @var Tasks_Model_Event
30      */
31     protected $_task;
32     
33     /**
34      * holds the vevent returned to the client
35      * 
36      * @var string
37      */
38     protected $_vevent;
39     
40     /**
41      * @var Tasks_Convert_Task_VCalendar
42      */
43     protected $_converter;
44     
45     /**
46      * Constructor 
47      * 
48      * @param  string|Tasks_Model_Event  $_task  the id of a event or the event itself 
49      */
50     public function __construct(Tinebase_Model_Container $_container, $_task = null) 
51     {
52         $this->_container = $_container;
53         $this->_task      = $_task;
54         
55         if (! $this->_task instanceof Tasks_Model_Task) {
56             $this->_task = ($pos = strpos($this->_task, '.')) === false ? $this->_task : substr($this->_task, 0, $pos);
57         } else {
58             // resolve alarms
59             Tasks_Controller_Task::getInstance()->getAlarms($this->_task);
60         }
61         
62         list($backend, $version) = Tasks_Convert_Task_VCalendar_Factory::parseUserAgent($_SERVER['HTTP_USER_AGENT']);
63         
64         $this->_converter = Tasks_Convert_Task_VCalendar_Factory::factory($backend, $version);
65     }
66     
67     /**
68      * this function creates a Tasks_Model_Task and stores it in the database
69      * 
70      * @param  Tinebase_Model_Container  $container
71      * @param  stream|string             $vobjectData
72      */
73     public static function create(Tinebase_Model_Container $container, $name, $vobjectData, $onlyCurrentUserOrganizer = 'unused')
74     {
75         if (is_resource($vobjectData)) {
76             $vobjectData = stream_get_contents($vobjectData);
77         }
78         // Converting to UTF-8, if needed
79         $vobjectData = Sabre\DAV\StringUtil::ensureUTF8($vobjectData);
80         
81         #Sabre_CalDAV_ICalendarUtil::validateICalendarObject($vobjectData, array('VTODO', 'VFREEBUSY'));
82         
83         list($backend, $version) = Tasks_Convert_Task_VCalendar_Factory::parseUserAgent($_SERVER['HTTP_USER_AGENT']);
84         
85         $task = Tasks_Convert_Task_VCalendar_Factory::factory($backend, $version)->toTine20Model($vobjectData);
86         $task->container_id = $container->getId();
87         $id = ($pos = strpos($name, '.')) === false ? $name : substr($name, 0, $pos);
88         $task->setId($id);
89         
90         self::enforceEventParameters($task);
91         
92         #if ($task->exdate instanceof Tinebase_Record_RecordSet) {
93         #    foreach($task->exdate as $exdate) {
94         #        if ($exdate->is_deleted == false && $exdate->organizer != $task->organizer) {
95         #            throw new Sabre\DAV\Exception\PreconditionFailed('Organizer for exdate must be the same like base task');
96         #        }
97         #    }
98         #}
99         
100         // check if there is already an existing task with this ID
101         // this can happen when the invitation email is faster then the caldav update or
102         // or when an task gets moved to another container
103         
104         $filter = new Tasks_Model_TaskFilter(array(
105             #array(
106             #    'field' => 'containerType', 
107             #    'operator' => 'equals', 
108             #    'value' => 'all'
109             #),
110             #array(
111             #    'field' => 'completed', 
112             #    'operator' => 'equals', 
113             #    'value' => $task->completed
114             #),
115             array(
116                 'field' => 'due', 
117                 'operator' => 'equals', 
118                 'value' => $task->due
119             ),
120             array('condition' => 'OR', 'filters' => array(
121                 array(
122                     'field'     => 'id',
123                     'operator'  => 'equals',
124                     'value'     => $id
125                 ),
126                 array(
127                     'field'     => 'uid',
128                     'operator'  => 'equals',
129                     'value'     => $id
130                 )
131             ))
132         ));
133         $existingEvent = Tasks_Controller_Task::getInstance()->search($filter, null, false, false, 'sync')->getFirstRecord();
134         
135         if ($existingEvent === null) {
136             $task = Tasks_Controller_Task::getInstance()->create($task);
137             
138             $vevent = new self($container, $task);
139         } else {
140             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) 
141                 Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . ' update existing task with id: ' . $existingEvent->getId());
142             $vevent = new self($container, $existingEvent);
143             $vevent->put($vobjectData);
144         }
145         
146         return $vevent;
147     }
148     
149     public static function enforceEventParameters(Tasks_Model_Task $task)
150     {
151         // can happen only during create not on update
152         if (empty($task->organizer)) {
153             $task->organizer = Tinebase_Core::getUser()->getId();
154         }
155
156     }
157
158     /**
159      * Deletes the card
160      *
161      * @todo improve handling
162      * @return void
163      */
164     public function delete() 
165     {
166         // when a move occurs, thunderbird first sends to delete command and immediately a put command
167         // we must delay the delete command, otherwise the put command fails
168         sleep(1);
169         
170         // (re) fetch task as tree move does not refresh src node before delete
171         $task = Tasks_Controller_Task::getInstance()->get($this->_task);
172         
173         // allow delete only if deleted in origin calendar
174         if ($task->container_id == $this->_container->getId()) {
175             //if (strpos($_SERVER['REQUEST_URI'], Calendar_Frontend_CalDAV_ScheduleInbox::NAME) === false) {
176                 Tasks_Controller_Task::getInstance()->delete($task->getId());
177             //}
178         }
179         
180         // implicitly DECLINE event 
181         //else {
182             //$attendee = $task->attendee instanceof Tinebase_Record_RecordSet ? 
183             //    $task->attendee->filter('displaycontainer_id', $this->_container->getId())->getFirstRecord() :
184             //    NULL;
185             
186             // NOTE: don't allow organizer to instantly delete after update, otherwise we can't handle move @see{Calendar_Frontend_WebDAV_EventTest::testMoveOriginPersonalToShared}
187         //    if ($attendee && $attendee->user_id != $task->organizer || Tinebase_DateTime::now()->subSecond(10) > $task->last_modified_time) {
188         //        $attendee->status = Calendar_Model_Attender::STATUS_DECLINED;
189        //         
190        //         self::enforceEventParameters($task);
191        //         $this->_task = Calendar_Controller_MSEventFacade::getInstance()->update($task);
192          //   } 
193        // }
194     }
195     
196     /**
197      * Returns the VCard-formatted object 
198      * 
199      * @return stream
200      */
201     public function get() 
202     {
203         $s = fopen('php://temp','r+');
204         fwrite($s, $this->_getVEvent());
205         rewind($s);
206         
207         return $s;
208     }
209     
210     /**
211      * Returns the uri for this object 
212      * 
213      * @return string 
214      */
215     public function getName() 
216     {
217         return $this->getRecord()->getId() . '.ics';
218     }
219     
220     /**
221      * Returns the owner principal
222      *
223      * This must be a url to a principal, or null if there's no owner 
224      * 
225      * @todo add real owner
226      * @return string|null
227      */
228     public function getOwner() 
229     {
230         return null;
231         return $this->addressBookInfo['principaluri'];
232     }
233
234     /**
235      * Returns a group principal
236      *
237      * This must be a url to a principal, or null if there's no owner
238      * 
239      * @todo add real group
240      * @return string|null 
241      */
242     public function getGroup() 
243     {
244         return null;
245     }
246     
247     /**
248      * Returns a list of ACE's for this node.
249      *
250      * Each ACE has the following properties:
251      *   * 'privilege', a string such as {DAV:}read or {DAV:}write. These are 
252      *     currently the only supported privileges
253      *   * 'principal', a url to the principal who owns the node
254      *   * 'protected' (optional), indicating that this ACE is not allowed to 
255      *      be updated. 
256      * 
257      * @todo add the real logic
258      * @return array 
259      */
260     public function getACL() 
261     {
262         return null;
263         
264         return array(
265             array(
266                 'privilege' => '{DAV:}read',
267                 'principal' => $this->addressBookInfo['principaluri'],
268                 'protected' => true,
269             ),
270             array(
271                 'privilege' => '{DAV:}write',
272                 'principal' => $this->addressBookInfo['principaluri'],
273                 'protected' => true,
274             ),
275         );
276
277     }
278     
279     /**
280      * Returns the mime content-type
281      *
282      * @return string
283      */
284     public function getContentType() {
285     
286         return 'text/calendar';
287     
288     }
289     
290     /**
291      * Returns an ETag for this object
292      *
293      * How to calculate the etag?
294      * The etag consists of 2 parts. The part, which is equal for all users (subject, dtstart, dtend, ...) and
295      * the part which is different for all users(X-MOZ-LASTACK for example).
296      * Because of the this we have to generate the etag as the hash of the record id, the lastmodified time stamp and the
297      * hash of the json encoded attendee object.
298      * This way the etag changes when the general properties or the user specific properties change.
299      * 
300      * @return string
301      */
302     public function getETag() 
303     {
304         $attendeeHash = null;
305         
306         //if ( ($ownAttendee = Calendar_Model_Attender::getOwnAttender($this->getRecord()->attendee)) instanceof Calendar_Model_Attender) {
307         //    $attendeeHash = sha1(Zend_Json::encode($ownAttendee->toArray()));
308         //}
309         
310         //if ($this->getRecord()->exdate instanceof Tinebase_Record_RecordSet) {
311         //    foreach ($this->getRecord()->exdate as $exdate) {
312         //        if ( ($ownAttendee = Calendar_Model_Attender::getOwnAttender($exdate->attendee)) instanceof Calendar_Model_Attender) {
313         //            $attendeeHash = sha1($attendeeHash . Zend_Json::encode($ownAttendee->toArray()));
314         //        }
315         //    }
316         // }
317         
318         return '"' . sha1($this->getRecord()->getId() . $this->getLastModified()) . $attendeeHash . '"'; 
319     }
320     
321     /**
322      * Returns the last modification date as a unix timestamp
323      *
324      * @return time
325      */
326     public function getLastModified() 
327     {
328         return ($this->getRecord()->last_modified_time instanceof Tinebase_DateTime) ? $this->getRecord()->last_modified_time->toString() : $this->getRecord()->creation_time->toString();
329     }
330     
331     /**
332      * Returns the size of the vcard in bytes
333      *
334      * @return int
335      */
336     public function getSize() 
337     {
338         return strlen($this->_getVEvent());
339     }
340     
341     /**
342      * Updates the VCard-formatted object
343      *
344      * @param string $cardData
345      * @return void
346      */
347     public function put($cardData) 
348     {
349         if (get_class($this->_converter) == 'Tasks_Convert_Task_VCalendar_Generic') {
350             if (Tinebase_Core::isLogLevel(Zend_Log::WARN)) 
351                 Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . " update by generic client not allowed. See Tasks_Convert_Task_VCalendar_Factory for supported clients.");
352             throw new Sabre\DAV\Exception\Forbidden('Update denied for unknow client');
353         }
354         
355         if (is_resource($cardData)) {
356             $cardData = stream_get_contents($cardData);
357         }
358         // Converting to UTF-8, if needed
359         $cardData = Sabre\DAV\StringUtil::ensureUTF8($cardData);
360         
361         #Sabre_CalDAV_ICalendarUtil::validateICalendarObject($cardData, array('VTODO', 'VFREEBUSY'));
362         
363         $vobject = Tasks_Convert_Task_VCalendar_Abstract::getVObject($cardData);
364         foreach ($vobject->children() as $component) {
365             if (isset($component->{'X-TINE20-CONTAINER'})) {
366                 $xContainerId = $component->{'X-TINE20-CONTAINER'};
367                 break;
368             }
369         }
370         
371         // keep old record for reference
372         $recordBeforeUpdate = clone $this->getRecord();
373         
374         $task = $this->_converter->toTine20Model($vobject, $this->getRecord(), array(
375             Tasks_Convert_Task_VCalendar_Abstract::OPTION_USE_SERVER_MODLOG => true,
376         ));
377         
378         // iCal does sends back an old value, because it does not refresh the vcalendar after 
379         // update. Therefor we must reapply the value of last_modified_time after the convert
380         $task->last_modified_time = $recordBeforeUpdate->last_modified_time;
381         
382         $currentContainer = Tinebase_Container::getInstance()->getContainerById($this->getRecord()->container_id);
383         //$ownAttendee = Calendar_Model_Attender::getOwnAttender($this->getRecord()->attendee);
384         
385         // task 'belongs' current user -> allow container move
386         if ($currentContainer->isPersonalOf(Tinebase_Core::getUser())) {
387             $task->container_id = $this->_container->getId();
388         }
389         
390         // client sends CalDAV task -> handle a container move
391         /*else if (isset($xContainerId)) {
392             if ($xContainerId == $currentContainer->getId()) {
393                 $task->container_id = $this->_container->getId();
394             } else {
395                 // @TODO allow organizer to move original cal when he edits the displaycal task?
396                 if ($ownAttendee && $this->_container->type == Tinebase_Model_Container::TYPE_PERSONAL) {
397                     $ownAttendee->displaycontainer_id = $this->_container->getId();
398                 }
399             }
400         }
401         */
402         // client sends task from iMIP invitation -> only allow displaycontainer move
403         /*else {
404             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
405                 Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " X-TINE20-CONTAINER not present -> restrict container moves");
406             if ($ownAttendee && $this->_container->type == Tinebase_Model_Container::TYPE_PERSONAL) {
407                 if ($ownAttendee->displaycontainer_id == $currentContainer->getId()) {
408                     $task->container_id = $this->_container->getId();
409                 }
410                 
411                 $ownAttendee->displaycontainer_id = $this->_container->getId();
412             }
413         }*/
414         
415         self::enforceEventParameters($task);
416         
417         // don't allow update of alarms for non organizer if oganizer is Tine 2.0 user
418         if ($task->organizer !== Tinebase_Core::getUser()->getId()) {
419             $organizerContact = Addressbook_Controller_Contact::getInstance()->getContactByUserId($task->organizer,TRUE);
420             
421             // reset alarms if organizer is Tine 2.0 user
422             if (!empty($organizerContact->account_id)) {
423                 $this->_resetAlarms($task, $recordBeforeUpdate);
424             }
425         }
426         
427         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(
428             __METHOD__ . '::' . __LINE__ . " " . print_r($task->toArray(), true));
429         
430         try {
431             $this->_task = Tasks_Controller_Task::getInstance()->update($task);
432         } catch (Tinebase_Timemachine_Exception_ConcurrencyConflict $ttecc) {
433             throw new Sabre\DAV\Exception\PreconditionFailed('An If-Match header was specified, but none of the specified the ETags matched.','If-Match');
434         }
435         
436         return $this->getETag();
437     }
438     
439     /**
440      * reset alarms to previous values
441      * 
442      * we don't reset the alarms in the vcalendar parser already, because this it is a limitation
443      * of our current calendar implementation to not allow user specific alarms
444      * 
445      * @param Calendar_Model_Event $task
446      * @param Calendar_Model_Event $recordBeforeUpdate
447      */
448     protected function _resetAlarms(Tasks_Model_Task $task, Tasks_Model_Task $recordBeforeUpdate)
449     {
450         $task->alarms = $recordBeforeUpdate->alarms;
451     
452         if ($task->exdate instanceof Tinebase_Record_RecordSet) {
453             foreach ($task->exdate as $exdate) {
454                 $recurId = $task->id . '-' . (string) $exdate->recurid;
455                 
456                 if ($recordBeforeUpdate->exdate instanceof Tinebase_Record_RecordSet && ($matchingRecord = $recordBeforeUpdate->exdate->find('recurid', $recurId)) !== null) {
457                     $exdate->alarms = $matchingRecord->alarms;
458                 } else {
459                     $exdate->alarms = new Tinebase_Record_RecordSet('Tinebase_Model_Alarm');
460                 }
461             }
462         }
463     }
464     
465     /**
466      * Updates the ACL
467      *
468      * This method will receive a list of new ACE's. 
469      * 
470      * @param array $acl 
471      * @return void
472      */
473     public function setACL(array $acl) 
474     {
475         throw new Sabre\DAV\Exception\MethodNotAllowed('Changing ACL is not yet supported');
476     }
477     
478     /**
479      * return Tasks_Model_Task and convert contact id to model if needed
480      * 
481      * @return Tasks_Model_Task
482      */
483     public function getRecord()
484     {
485         if (! $this->_task instanceof Tasks_Model_Task) {
486             $this->_task = Tasks_Controller_Task::getInstance()->get($this->_task);
487             
488             // resolve alarms
489             //Tasks_Controller_Task::getInstance()->getAlarms($this->_task);
490             
491             Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__ . " " . print_r($this->_task->toArray(), true));
492         }
493
494         return $this->_task;
495     }
496     
497     /**
498      * return vcard and convert Calendar_Model_Event to vcard if needed
499      * 
500      * @return string
501      */
502     protected function _getVEvent()
503     {
504         if ($this->_vevent == null) {
505             $this->_vevent = $this->_converter->fromTine20Model($this->getRecord());
506             
507             foreach ($this->_vevent->children() as $component) {
508                 if ($component->name == 'VTODO') {
509                     // NOTE: we store the requested container here to have an origin when the event is moved
510                     $component->{'X-TINE20-CONTAINER'} = $this->_container->getId();
511                     
512                     if (isset($component->{'VALARM'}) && !$this->_container->isPersonalOf(Tinebase_Core::getUser())) {
513                         // prevent duplicate alarms
514                         $component->add('X-MOZ-LASTACK', Tinebase_DateTime::now()->addYear(100)->setTimezone('UTC'), array('VALUE' => 'DATE-TIME'));
515                     }
516                 }
517             }
518         }
519         
520         return $this->_vevent->serialize();
521     }
522     
523     /**
524      * 
525      */
526     public function getSupportedPrivilegeSet()
527     {
528         return null;
529     }
530 }