1<?php
2
3/**
4 * Tasks plugin for Roundcube webmail
5 *
6 * @version @package_version@
7 * @author Thomas Bruederli <bruederli@kolabsys.com>
8 *
9 * Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com>
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Affero General Public License as
13 * published by the Free Software Foundation, either version 3 of the
14 * License, or (at your option) any later version.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU Affero General Public License for more details.
20 *
21 * You should have received a copy of the GNU Affero General Public License
22 * along with this program. If not, see <http://www.gnu.org/licenses/>.
23 */
24
25class tasklist extends rcube_plugin
26{
27    const FILTER_MASK_TODAY = 1;
28    const FILTER_MASK_TOMORROW = 2;
29    const FILTER_MASK_WEEK = 4;
30    const FILTER_MASK_LATER = 8;
31    const FILTER_MASK_NODATE = 16;
32    const FILTER_MASK_OVERDUE = 32;
33    const FILTER_MASK_FLAGGED = 64;
34    const FILTER_MASK_COMPLETE = 128;
35    const FILTER_MASK_ASSIGNED = 256;
36    const FILTER_MASK_MYTASKS = 512;
37
38    const SESSION_KEY = 'tasklist_temp';
39
40    public static $filter_masks = array(
41        'today'    => self::FILTER_MASK_TODAY,
42        'tomorrow' => self::FILTER_MASK_TOMORROW,
43        'week'     => self::FILTER_MASK_WEEK,
44        'later'    => self::FILTER_MASK_LATER,
45        'nodate'   => self::FILTER_MASK_NODATE,
46        'overdue'  => self::FILTER_MASK_OVERDUE,
47        'flagged'  => self::FILTER_MASK_FLAGGED,
48        'complete' => self::FILTER_MASK_COMPLETE,
49        'assigned' => self::FILTER_MASK_ASSIGNED,
50        'mytasks'  => self::FILTER_MASK_MYTASKS,
51    );
52
53    public $task = '?(?!login|logout).*';
54    public $allowed_prefs = array('tasklist_sort_col','tasklist_sort_order');
55
56    public $rc;
57    public $lib;
58    public $timezone;
59    public $ui;
60    public $home;  // declare public to be used in other classes
61
62    // These are handled by __get()
63    // public $driver;
64    // public $itip;
65    // public $ical;
66
67    private $collapsed_tasks = array();
68    private $message_tasks   = array();
69
70
71    /**
72     * Plugin initialization.
73     */
74    function init()
75    {
76        $this->require_plugin('libcalendaring');
77        $this->require_plugin('libkolab');
78
79        $this->rc  = rcube::get_instance();
80        $this->lib = libcalendaring::get_instance();
81
82        $this->register_task('tasks', 'tasklist');
83
84        // load plugin configuration
85        $this->load_config();
86
87        $this->timezone = $this->lib->timezone;
88
89        // proceed initialization in startup hook
90        $this->add_hook('startup', array($this, 'startup'));
91
92        $this->add_hook('user_delete', array($this, 'user_delete'));
93    }
94
95    /**
96     * Startup hook
97     */
98    public function startup($args)
99    {
100        // the tasks module can be enabled/disabled by the kolab_auth plugin
101        if ($this->rc->config->get('tasklist_disabled', false) || !$this->rc->config->get('tasklist_enabled', true))
102            return;
103
104        // load localizations
105        $this->add_texts('localization/', $args['task'] == 'tasks' && (!$args['action'] || $args['action'] == 'print'));
106        $this->rc->load_language($_SESSION['language'], array('tasks.tasks' => $this->gettext('navtitle')));  // add label for task title
107
108        if ($args['task'] == 'tasks' && $args['action'] != 'save-pref') {
109            $this->load_driver();
110
111            // register calendar actions
112            $this->register_action('index', array($this, 'tasklist_view'));
113            $this->register_action('task', array($this, 'task_action'));
114            $this->register_action('tasklist', array($this, 'tasklist_action'));
115            $this->register_action('counts', array($this, 'fetch_counts'));
116            $this->register_action('fetch', array($this, 'fetch_tasks'));
117            $this->register_action('print', array($this, 'print_tasks'));
118            $this->register_action('dialog-ui', array($this, 'mail_message2task'));
119            $this->register_action('get-attachment', array($this, 'attachment_get'));
120            $this->register_action('upload', array($this, 'attachment_upload'));
121            $this->register_action('import', array($this, 'import_tasks'));
122            $this->register_action('export', array($this, 'export_tasks'));
123            $this->register_action('mailimportitip', array($this, 'mail_import_itip'));
124            $this->register_action('mailimportattach', array($this, 'mail_import_attachment'));
125            $this->register_action('itip-status', array($this, 'task_itip_status'));
126            $this->register_action('itip-remove', array($this, 'task_itip_remove'));
127            $this->register_action('itip-decline-reply', array($this, 'mail_itip_decline_reply'));
128            $this->register_action('itip-delegate', array($this, 'mail_itip_delegate'));
129            $this->add_hook('refresh', array($this, 'refresh'));
130
131            $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', '')));
132        }
133        else if ($args['task'] == 'mail') {
134            if ($args['action'] == 'show' || $args['action'] == 'preview') {
135                if ($this->rc->config->get('tasklist_mail_embed', true)) {
136                    $this->add_hook('message_load', array($this, 'mail_message_load'));
137                }
138                $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html'));
139            }
140
141            // add 'Create event' item to message menu
142            if ($this->api->output->type == 'html' && $_GET['_rel'] != 'task') {
143                $this->api->add_content(html::tag('li', array('role' => 'menuitem'),
144                    $this->api->output->button(array(
145                        'command'  => 'tasklist-create-from-mail',
146                        'label'    => 'tasklist.createfrommail',
147                        'type'     => 'link',
148                        'classact' => 'icon taskaddlink active',
149                        'class'    => 'icon taskaddlink disabled',
150                        'innerclass' => 'icon taskadd',
151                    ))),
152                'messagemenu');
153
154                $this->api->output->add_label('tasklist.createfrommail');
155            }
156        }
157
158        if (!$this->rc->output->ajax_call && !$this->rc->output->env['framed']) {
159            $this->load_ui();
160            $this->ui->init();
161        }
162
163        // add hooks for alarms handling
164        $this->add_hook('pending_alarms', array($this, 'pending_alarms'));
165        $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms'));
166    }
167
168    /**
169     *
170     */
171    private function load_ui()
172    {
173        if (!$this->ui) {
174            require_once($this->home . '/tasklist_ui.php');
175            $this->ui = new tasklist_ui($this);
176        }
177    }
178
179    /**
180     * Helper method to load the backend driver according to local config
181     */
182    private function load_driver()
183    {
184        if (is_object($this->driver)) {
185            return;
186        }
187
188        $driver_name  = $this->rc->config->get('tasklist_driver', 'database');
189        $driver_class = 'tasklist_' . $driver_name . '_driver';
190
191        require_once($this->home . '/drivers/tasklist_driver.php');
192        require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php');
193
194        $this->driver = new $driver_class($this);
195
196        $this->rc->output->set_env('tasklist_driver', $driver_name);
197    }
198
199    /**
200     * Dispatcher for task-related actions initiated by the client
201     */
202    public function task_action()
203    {
204        $filter = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC));
205        $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
206        $rec    = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true);
207        $oldrec = $rec;
208        $success = $refresh = $got_msg = false;
209
210        // force notify if hidden + active
211        $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3);
212        if ($itip_send_option === 1 && empty($rec['_reportpartstat']))
213            $rec['_notify'] = 1;
214
215        switch ($action) {
216        case 'new':
217            $oldrec = null;
218            $rec = $this->prepare_task($rec);
219            $rec['uid'] = $this->generate_uid();
220            $temp_id = $rec['tempid'];
221            if ($success = $this->driver->create_task($rec)) {
222                $refresh = $this->driver->get_task($rec);
223                if ($temp_id) $refresh['tempid'] = $temp_id;
224                $this->cleanup_task($rec);
225            }
226            break;
227
228        case 'complete':
229            $complete = intval(rcube_utils::get_input_value('complete', rcube_utils::INPUT_POST));
230            if (!($rec = $this->driver->get_task($rec))) {
231                break;
232            }
233
234            $oldrec = $rec;
235            $rec['status'] = $complete ? 'COMPLETED' : ($rec['complete'] > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION');
236
237            // sent itip notifications if enabled (no user interaction here)
238            if (($itip_send_option & 1)) {
239                if ($this->is_attendee($rec)) {
240                    $rec['_reportpartstat'] = $rec['status'];
241                }
242                else if ($this->is_organizer($rec)) {
243                    $rec['_notify'] = 1;
244                }
245            }
246
247        case 'edit':
248            $oldrec = $this->driver->get_task($rec);
249            $rec = $this->prepare_task($rec);
250            $clone = $this->handle_recurrence($rec, $this->driver->get_task($rec));
251            if ($success = $this->driver->edit_task($rec)) {
252                $new_task = $this->driver->get_task($rec);
253                $new_task['tempid'] = $rec['id'];
254                $refresh[] = $new_task;
255                $this->cleanup_task($rec);
256
257                // add clone from recurring task
258                if ($clone && $this->driver->create_task($clone)) {
259                    $new_clone = $this->driver->get_task($clone);
260                    $new_clone['tempid'] = $clone['id'];
261                    $refresh[] = $new_clone;
262                    $this->driver->clear_alarms($rec['id']);
263                }
264
265                // move all childs if list assignment was changed
266                if (!empty($rec['_fromlist']) && !empty($rec['list']) && $rec['_fromlist'] != $rec['list']) {
267                    foreach ($this->driver->get_childs(array('id' => $rec['id'], 'list' => $rec['_fromlist']), true) as $cid) {
268                        $child = array('id' => $cid, 'list' => $rec['list'], '_fromlist' => $rec['_fromlist']);
269                        if ($this->driver->move_task($child)) {
270                            $r = $this->driver->get_task($child);
271                            if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) {
272                                $r['tempid'] = $cid;
273                                $refresh[] = $r;
274                            }
275                        }
276                    }
277                }
278            }
279            break;
280
281          case 'move':
282              foreach ((array)$rec['id'] as $id) {
283                  $r = $rec;
284                  $r['id'] = $id;
285                  if ($this->driver->move_task($r)) {
286                      $new_task = $this->driver->get_task($r);
287                      $new_task['tempid'] = $id;
288                      $refresh[] = $new_task;
289                      $success = true;
290
291                      // move all childs, too
292                      foreach ($this->driver->get_childs(array('id' => $id, 'list' => $rec['_fromlist']), true) as $cid) {
293                          $child = $rec;
294                          $child['id'] = $cid;
295                          if ($this->driver->move_task($child)) {
296                              $r = $this->driver->get_task($child);
297                              if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) {
298                                  $r['tempid'] = $cid;
299                                  $refresh[] = $r;
300                              }
301                          }
302                      }
303                  }
304              }
305              break;
306
307        case 'delete':
308            $mode  = intval(rcube_utils::get_input_value('mode', rcube_utils::INPUT_POST));
309            $oldrec = $this->driver->get_task($rec);
310            if ($success = $this->driver->delete_task($rec, false)) {
311                // delete/modify all childs
312                foreach ($this->driver->get_childs($rec, $mode) as $cid) {
313                    $child = array('id' => $cid, 'list' => $rec['list']);
314
315                    if ($mode == 1) {  // delete all childs
316                        if ($this->driver->delete_task($child, false)) {
317                            if ($this->driver->undelete)
318                                $_SESSION['tasklist_undelete'][$rec['id']][] = $cid;
319                        }
320                        else
321                            $success = false;
322                    }
323                    else {
324                        $child['parent_id'] = strval($oldrec['parent_id']);
325                        $this->driver->edit_task($child);
326                    }
327                }
328                // update parent task to adjust list of children
329                if (!empty($oldrec['parent_id'])) {
330                    $parent = array('id' => $oldrec['parent_id'], 'list' => $rec['list']);
331                    if ($parent = $this->driver->get_task()) {
332                        $refresh[] = $parent;
333                    }
334                }
335            }
336
337            if (!$success)
338                $this->rc->output->command('plugin.reload_data');
339            break;
340
341        case 'undelete':
342            if ($success = $this->driver->undelete_task($rec)) {
343                $refresh[] = $this->driver->get_task($rec);
344                foreach ((array)$_SESSION['tasklist_undelete'][$rec['id']] as $cid) {
345                    if ($this->driver->undelete_task($rec)) {
346                        $refresh[] = $this->driver->get_task($rec);
347                    }
348                }
349            }
350            break;
351
352        case 'collapse':
353            foreach (explode(',', $rec['id']) as $rec_id) {
354                if (intval(rcube_utils::get_input_value('collapsed', rcube_utils::INPUT_GPC))) {
355                    $this->collapsed_tasks[] = $rec_id;
356                }
357                else {
358                    $i = array_search($rec_id, $this->collapsed_tasks);
359                    if ($i !== false)
360                        unset($this->collapsed_tasks[$i]);
361                }
362            }
363
364            $this->rc->user->save_prefs(array('tasklist_collapsed_tasks' => join(',', array_unique($this->collapsed_tasks))));
365            return;  // avoid further actions
366
367        case 'rsvp':
368            $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_GPC);
369            $noreply = intval(rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC)) || $status == 'needs-action';
370            $task = $this->driver->get_task($rec);
371            $task['attendees'] = $rec['attendees'];
372            $task['_type'] = 'task';
373
374            // send invitation to delegatee + add it as attendee
375            if ($status == 'delegated' && $rec['to']) {
376                $itip = $this->load_itip();
377                if ($itip->delegate_to($task, $rec['to'], (bool)$rec['rsvp'])) {
378                    $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation');
379                    $refresh[] = $task;
380                    $noreply = false;
381                }
382            }
383
384            $rec = $task;
385
386            if ($success = $this->driver->edit_task($rec)) {
387                if (!$noreply) {
388                    // let the reply clause further down send the iTip message
389                    $rec['_reportpartstat'] = $status;
390                }
391            }
392            break;
393
394        case 'changelog':
395            $data = $this->driver->get_task_changelog($rec);
396            if (is_array($data) && !empty($data)) {
397                $lib = $this->lib;
398                $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format');
399                array_walk($data, function(&$change) use ($lib, $dtformat) {
400                  if ($change['date']) {
401                      $dt = $lib->adjust_timezone($change['date']);
402                      if ($dt instanceof DateTime) {
403                          $change['date'] = $this->rc->format_date($dt, $dtformat, false);
404                      }
405                  }
406                });
407                $this->rc->output->command('plugin.task_render_changelog', $data);
408            }
409            else {
410                $this->rc->output->command('plugin.task_render_changelog', false);
411            }
412            $got_msg = true;
413            break;
414
415        case 'diff':
416            $data = $this->driver->get_task_diff($rec, $rec['rev1'], $rec['rev2']);
417            if (is_array($data)) {
418                // convert some properties, similar to self::_client_event()
419                $lib = $this->lib;
420                $date_format = $this->rc->config->get('date_format', 'Y-m-d');
421                $time_format = $this->rc->config->get('time_format', 'H:i');
422                array_walk($data['changes'], function(&$change, $i) use ($lib, $date_format, $time_format) {
423                    // convert date cols
424                    if (in_array($change['property'], array('date','start','created','changed'))) {
425                        if (!empty($change['old'])) {
426                            $dtformat = strlen($change['old']) == 10 ? $date_format : $date_format . ' ' . $time_format;
427                            $change['old_'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format($dtformat);
428                        }
429                        if (!empty($change['new'])) {
430                            $dtformat = strlen($change['new']) == 10 ? $date_format : $date_format . ' ' . $time_format;
431                            $change['new_'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format($dtformat);
432                        }
433                    }
434                    // create textual representation for alarms and recurrence
435                    if ($change['property'] == 'alarms') {
436                        if (is_array($change['old']))
437                            $change['old_'] = libcalendaring::alarm_text($change['old']);
438                        if (is_array($change['new']))
439                            $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new']));
440                    }
441                    if ($change['property'] == 'recurrence') {
442                        if (is_array($change['old']))
443                            $change['old_'] = $lib->recurrence_text($change['old']);
444                        if (is_array($change['new']))
445                            $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new']));
446                    }
447                    if ($change['property'] == 'complete') {
448                        $change['old_'] = intval($change['old']) . '%';
449                        $change['new_'] = intval($change['new']) . '%';
450                    }
451                    if ($change['property'] == 'attachments') {
452                        if (is_array($change['old']))
453                            $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']);
454                        if (is_array($change['new'])) {
455                            $change['new'] = array_merge((array)$change['old'], $change['new']);
456                            $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']);
457                        }
458                    }
459                    // resolve parent_id to the refered task title for display
460                    if ($change['property'] == 'parent_id') {
461                        $change['property'] = 'parent-title';
462                        if (!empty($change['old']) && ($old_parent = $this->driver->get_task(array('id' => $change['old'], 'list' => $rec['list'])))) {
463                            $change['old_'] = $old_parent['title'];
464                        }
465                        if (!empty($change['new']) && ($new_parent = $this->driver->get_task(array('id' => $change['new'], 'list' => $rec['list'])))) {
466                            $change['new_'] = $new_parent['title'];
467                        }
468                    }
469                    // compute a nice diff of description texts
470                    if ($change['property'] == 'description') {
471                        $change['diff_'] = libkolab::html_diff($change['old'], $change['new']);
472                    }
473                });
474                $this->rc->output->command('plugin.task_show_diff', $data);
475            }
476            else {
477                $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error');
478            }
479            $got_msg = true;
480            break;
481
482        case 'show':
483            if ($rec = $this->driver->get_task_revison($rec, $rec['rev'])) {
484                $this->encode_task($rec);
485                $rec['readonly'] = 1;
486                $this->rc->output->command('plugin.task_show_revision', $rec);
487            }
488            else {
489                $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error');
490            }
491            $got_msg = true;
492            break;
493
494        case 'restore':
495            if ($success = $this->driver->restore_task_revision($rec, $rec['rev'])) {
496                $refresh = $this->driver->get_task($rec);
497                $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rec['rev']))), 'confirmation');
498                $this->rc->output->command('plugin.close_history_dialog');
499            }
500            else {
501                $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error');
502            }
503            $got_msg = true;
504            break;
505
506        }
507
508        if ($success) {
509            $this->rc->output->show_message('successfullysaved', 'confirmation');
510            $this->update_counts($oldrec, $refresh);
511        }
512        else if (!$got_msg) {
513            $this->rc->output->show_message('tasklist.errorsaving', 'error');
514        }
515
516        // send out notifications
517        if ($success && $rec['_notify'] && ($rec['attendees'] || $oldrec['attendees'])) {
518            // make sure we have the complete record
519            $task = $action == 'delete' ? $oldrec : $this->driver->get_task($rec);
520
521            // only notify if data really changed (TODO: do diff check on client already)
522            if (!$oldrec || $action == 'delete' || self::task_diff($task, $oldrec)) {
523                $sent = $this->notify_attendees($task, $oldrec, $action, $rec['_comment']);
524                if ($sent > 0)
525                    $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation');
526                else if ($sent < 0)
527                    $this->rc->output->show_message('tasklist.errornotifying', 'error');
528            }
529        }
530
531        if ($success && $rec['_reportpartstat'] && $rec['_reportpartstat'] != 'NEEDS-ACTION') {
532            // get the full record after update
533            if (!$task) {
534                $task = $this->driver->get_task($rec);
535            }
536
537            // send iTip REPLY with the updated partstat
538            if ($task['organizer'] && ($idx = $this->is_attendee($task)) !== false) {
539                $sender = $task['attendees'][$idx];
540                $status = strtolower($sender['status']);
541
542                if (!empty($_POST['comment']))
543                    $task['comment'] = rcube_utils::get_input_value('comment', rcube_utils::INPUT_POST);
544
545                $itip = $this->load_itip();
546                $itip->set_sender_email($sender['email']);
547
548                if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status))
549                    $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation');
550                else
551                    $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
552            }
553        }
554
555        // unlock client
556        $this->rc->output->command('plugin.unlock_saving', $success);
557
558        if ($refresh) {
559            if ($refresh['id']) {
560                $this->encode_task($refresh);
561            }
562            else if (is_array($refresh)) {
563                foreach ($refresh as $i => $r)
564                    $this->encode_task($refresh[$i]);
565            }
566            $this->rc->output->command('plugin.update_task', $refresh);
567        }
568        else if ($success && ($action == 'delete' || $action == 'undelete')) {
569            $this->rc->output->command('plugin.refresh_tagcloud');
570        }
571    }
572
573    /**
574     * Load iTIP functions
575     */
576    private function load_itip()
577    {
578        if (!$this->itip) {
579            require_once realpath(__DIR__ . '/../libcalendaring/lib/libcalendaring_itip.php');
580            $this->itip = new libcalendaring_itip($this, 'tasklist');
581            $this->itip->set_rsvp_actions(array('accepted','declined','delegated'));
582            $this->itip->set_rsvp_status(array('accepted','tentative','declined','delegated','in-process','completed'));
583        }
584
585        return $this->itip;
586    }
587
588    /**
589     * repares new/edited task properties before save
590     */
591    private function prepare_task($rec)
592    {
593        // try to be smart and extract date from raw input
594        if ($rec['raw']) {
595            foreach (array('today','tomorrow','sunday','monday','tuesday','wednesday','thursday','friday','saturday','sun','mon','tue','wed','thu','fri','sat') as $word) {
596                $locwords[] = '/^' . preg_quote(mb_strtolower($this->gettext($word))) . '\b/i';
597                $normwords[] = $word;
598                $datewords[] = $word;
599            }
600            foreach (array('jan','feb','mar','apr','may','jun','jul','aug','sep','oct','now','dec') as $month) {
601                $locwords[] = '/(' . preg_quote(mb_strtolower($this->gettext('long'.$month))) . '|' . preg_quote(mb_strtolower($this->gettext($month))) . ')\b/i';
602                $normwords[] = $month;
603                $datewords[] = $month;
604            }
605            foreach (array('on','this','next','at') as $word) {
606                $fillwords[] = preg_quote(mb_strtolower($this->gettext($word)));
607                $fillwords[] = $word;
608            }
609
610            $raw = trim($rec['raw']);
611            $date_str = '';
612
613            // translate localized keywords
614            $raw = preg_replace('/^(' . join('|', $fillwords) . ')\s*/i', '', $raw);
615            $raw = preg_replace($locwords, $normwords, $raw);
616
617            // find date pattern
618            $date_pattern = '!^(\d+[./-]\s*)?((?:\d+[./-])|' . join('|', $datewords) . ')\.?(\s+\d{4})?[:;,]?\s+!i';
619            if (preg_match($date_pattern, $raw, $m)) {
620                $date_str .= $m[1] . $m[2] . $m[3];
621                $raw = preg_replace(array($date_pattern, '/^(' . join('|', $fillwords) . ')\s*/i'), '', $raw);
622                // add year to date string
623                if ($m[1] && !$m[3])
624                    $date_str .= date('Y');
625            }
626
627            // find time pattern
628            $time_pattern = '/^(\d+([:.]\d+)?(\s*[hapm.]+)?),?\s+/i';
629            if (preg_match($time_pattern, $raw, $m)) {
630                $has_time = true;
631                $date_str .= ($date_str ? ' ' : 'today ') . $m[1];
632                $raw = preg_replace($time_pattern, '', $raw);
633            }
634
635            // yes, raw input matched a (valid) date
636            if (strlen($date_str) && strtotime($date_str) && ($date = new DateTime($date_str, $this->timezone))) {
637                $rec['date'] = $date->format('Y-m-d');
638                if ($has_time)
639                    $rec['time'] = $date->format('H:i');
640                $rec['title'] = $raw;
641            }
642            else
643                $rec['title'] = $rec['raw'];
644        }
645
646        // normalize input from client
647        if (isset($rec['complete'])) {
648            $rec['complete'] = floatval($rec['complete']);
649            if ($rec['complete'] > 1)
650                $rec['complete'] /= 100;
651        }
652        if (isset($rec['flagged']))
653            $rec['flagged'] = intval($rec['flagged']);
654
655        // fix for garbage input
656        if ($rec['description'] == 'null')
657            $rec['description'] = '';
658
659        foreach ($rec as $key => $val) {
660            if ($val === 'null')
661                $rec[$key] = null;
662        }
663
664        if (!empty($rec['date'])) {
665            $this->normalize_dates($rec, 'date', 'time');
666        }
667
668        if (!empty($rec['startdate'])) {
669            $this->normalize_dates($rec, 'startdate', 'starttime');
670        }
671
672        // convert tags to array, filter out empty entries
673        if (isset($rec['tags']) && !is_array($rec['tags'])) {
674            $rec['tags'] = array_filter((array)$rec['tags']);
675        }
676
677        // convert the submitted alarm values
678        if ($rec['valarms']) {
679            $valarms = array();
680            foreach (libcalendaring::from_client_alarms($rec['valarms']) as $alarm) {
681                // alarms can only work with a date (either task start, due or absolute alarm date)
682                if (is_a($alarm['trigger'], 'DateTime') || $rec['date'] || $rec['startdate'])
683                    $valarms[] = $alarm;
684            }
685            $rec['valarms'] = $valarms;
686        }
687
688        // convert the submitted recurrence settings
689        if (is_array($rec['recurrence'])) {
690            $refdate = null;
691            if (!empty($rec['date'])) {
692                $refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone);
693            }
694            else if (!empty($rec['startdate'])) {
695                $refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone);
696            }
697
698            if ($refdate) {
699                $rec['recurrence'] = $this->lib->from_client_recurrence($rec['recurrence'], $refdate);
700
701                // translate count into an absolute end date.
702                // why? because when shifting completed tasks to the next recurrence,
703                // the initial start date to count from gets lost.
704                if ($rec['recurrence']['COUNT']) {
705                    $engine = libcalendaring::get_recurrence();
706                    $engine->init($rec['recurrence'], $refdate);
707                    if ($until = $engine->end()) {
708                        $rec['recurrence']['UNTIL'] = $until;
709                        unset($rec['recurrence']['COUNT']);
710                    }
711                }
712            }
713            else {  // recurrence requires a reference date
714                $rec['recurrence'] = '';
715            }
716        }
717
718        $attachments = array();
719        $taskid = $rec['id'];
720        if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $taskid) {
721            if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) {
722                foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) {
723                    if (is_array($rec['attachments']) && in_array($id, $rec['attachments'])) {
724                        $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment);
725                        unset($attachments[$id]['abort'], $attachments[$id]['group']);
726                    }
727                }
728            }
729        }
730
731        $rec['attachments'] = $attachments;
732
733        // convert link references into simple URIs
734        if (array_key_exists('links', $rec)) {
735            $rec['links'] = array_map(function($link) { return is_array($link) ? $link['uri'] : strval($link); }, (array)$rec['links']);
736        }
737
738        // convert invalid data
739        if (isset($rec['attendees']) && !is_array($rec['attendees']))
740            $rec['attendees'] = array();
741
742        foreach ((array)$rec['attendees'] as $i => $attendee) {
743            if (is_string($attendee['rsvp'])) {
744                $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1';
745            }
746        }
747
748        // copy the task status to my attendee partstat
749        if (!empty($rec['_reportpartstat'])) {
750            if (($idx = $this->is_attendee($rec)) !== false) {
751                if (!($rec['_reportpartstat'] == 'NEEDS-ACTION' && $rec['attendees'][$idx]['status'] == 'ACCEPTED'))
752                    $rec['attendees'][$idx]['status'] = $rec['_reportpartstat'];
753                else
754                    unset($rec['_reportpartstat']);
755            }
756        }
757
758        // set organizer from identity selector
759        if ((isset($rec['_identity']) || (!empty($rec['attendees']) && empty($rec['organizer']))) &&
760                ($identity = $this->rc->user->get_identity($rec['_identity']))) {
761            $rec['organizer'] = array('name' => $identity['name'], 'email' => $identity['email']);
762        }
763
764        if (is_numeric($rec['id']) && $rec['id'] < 0)
765            unset($rec['id']);
766
767        return $rec;
768    }
769
770    /**
771     * Utility method to convert a tasks date/time values into a normalized format
772     */
773    private function normalize_dates(&$rec, $date_key, $time_key)
774    {
775        try {
776            // parse date from user format (#2801)
777            $date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d');
778            $date = DateTime::createFromFormat($date_format, trim($rec[$date_key] . ' ' . $rec[$time_key]), $this->timezone);
779
780            // fall back to default strtotime logic
781            if (empty($date)) {
782                $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone);
783            }
784
785            $rec[$date_key] = $date->format('Y-m-d');
786            if (!empty($rec[$time_key]))
787                $rec[$time_key] = $date->format('H:i');
788
789            return true;
790        }
791        catch (Exception $e) {
792            $rec[$date_key] = $rec[$time_key] = null;
793        }
794
795        return false;
796    }
797
798    /**
799     * Releases some resources after successful save
800     */
801    private function cleanup_task(&$rec)
802    {
803        // remove temp. attachment files
804        if (!empty($_SESSION[self::SESSION_KEY]) && ($taskid = $_SESSION[self::SESSION_KEY]['id'])) {
805            $this->rc->plugins->exec_hook('attachments_cleanup', array('group' => $taskid));
806            $this->rc->session->remove(self::SESSION_KEY);
807        }
808    }
809
810    /**
811     * When flagging a recurring task as complete,
812     * clone it and shift dates to the next occurrence
813     */
814    private function handle_recurrence(&$rec, $old)
815    {
816        $clone = null;
817        if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && is_array($rec['recurrence'])) {
818            $engine = libcalendaring::get_recurrence();
819            $rrule = $rec['recurrence'];
820            $updates = array();
821
822            // compute the next occurrence of date attributes
823            foreach (array('date'=>'time', 'startdate'=>'starttime') as $date_key => $time_key) {
824                if (empty($rec[$date_key]))
825                    continue;
826
827                $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone);
828                $engine->init($rrule, $date);
829                if ($next = $engine->next()) {
830                    $updates[$date_key] = $next->format('Y-m-d');
831                    if (!empty($rec[$time_key]))
832                        $updates[$time_key] = $next->format('H:i');
833                }
834            }
835
836            // shift absolute alarm dates
837            if (!empty($updates) && is_array($rec['valarms'])) {
838                $updates['valarms'] = array();
839                unset($rrule['UNTIL'], $rrule['COUNT']);  // make recurrence rule unlimited
840
841                foreach ($rec['valarms'] as $i => $alarm) {
842                    if ($alarm['trigger'] instanceof DateTime) {
843                        $engine->init($rrule, $alarm['trigger']);
844                        if ($next = $engine->next()) {
845                            $alarm['trigger'] = $next;
846                        }
847                    }
848                    $updates['valarms'][$i] = $alarm;
849                }
850            }
851
852            if (!empty($updates)) {
853                // clone task to save a completed copy
854                $clone = $rec;
855                $clone['uid'] = $this->generate_uid();
856                $clone['parent_id'] = $rec['id'];
857                unset($clone['id'], $clone['recurrence'], $clone['attachments']);
858
859                // update the task but unset completed flag
860                $rec = array_merge($rec, $updates);
861                $rec['complete'] = $old['complete'];
862                $rec['status'] = $old['status'];
863            }
864        }
865
866        return $clone;
867    }
868
869    /**
870     * Send out an invitation/notification to all task attendees
871     */
872    private function notify_attendees($task, $old, $action = 'edit', $comment = null)
873    {
874        if ($action == 'delete' || ($task['status'] == 'CANCELLED' && $old['status'] != $task['status'])) {
875            $task['cancelled'] = true;
876            $is_cancelled      = true;
877        }
878
879        $itip   = $this->load_itip();
880        $emails = $this->lib->get_user_emails();
881        $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3);
882
883        // add comment to the iTip attachment
884        $task['comment'] = $comment;
885
886        // needed to generate VTODO instead of VEVENT entry
887        $task['_type'] = 'task';
888
889        // compose multipart message using PEAR:Mail_Mime
890        $method  = $action == 'delete' ? 'CANCEL' : 'REQUEST';
891        $object = $this->to_libcal($task);
892        $message = $itip->compose_itip_message($object, $method, $task['sequence'] > $old['sequence']);
893
894        // list existing attendees from the $old task
895        $old_attendees = array();
896        foreach ((array)$old['attendees'] as $attendee) {
897            $old_attendees[] = $attendee['email'];
898        }
899
900        // send to every attendee
901        $sent = 0; $current = array();
902        foreach ((array)$task['attendees'] as $attendee) {
903            $current[] = strtolower($attendee['email']);
904
905            // skip myself for obvious reasons
906            if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) {
907                continue;
908            }
909
910            // skip if notification is disabled for this attendee
911            if ($attendee['noreply'] && $itip_notify & 2) {
912                continue;
913            }
914
915            // skip if this attendee has delegated and set RSVP=FALSE
916            if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) {
917                continue;
918            }
919
920            // which template to use for mail text
921            $is_new   = !in_array($attendee['email'], $old_attendees);
922            $is_rsvp  = $is_new || $task['sequence'] > $old['sequence'];
923            $bodytext = $is_cancelled ? 'itipcancelmailbody' : ($is_new ? 'invitationmailbody' : 'itipupdatemailbody');
924            $subject  = $is_cancelled ? 'itipcancelsubject'  : ($is_new ? 'invitationsubject' : ($task['title'] ? 'itipupdatesubject' : 'itipupdatesubjectempty'));
925
926            // finally send the message
927            if ($itip->send_itip_message($object, $method, $attendee, $subject, $bodytext, $message, $is_rsvp))
928                $sent++;
929            else
930                $sent = -100;
931        }
932
933        // send CANCEL message to removed attendees
934        foreach ((array)$old['attendees'] as $attendee) {
935            if (!$attendee['email'] || in_array(strtolower($attendee['email']), $current)) {
936                continue;
937            }
938
939            $vtodo = $this->to_libcal($old);
940            $vtodo['cancelled'] = $is_cancelled;
941            $vtodo['attendees'] = array($attendee);
942            $vtodo['comment']   = $comment;
943
944            if ($itip->send_itip_message($vtodo, 'CANCEL', $attendee, 'itipcancelsubject', 'itipcancelmailbody'))
945                $sent++;
946            else
947                $sent = -100;
948        }
949
950        return $sent;
951    }
952
953    /**
954     * Compare two task objects and return differing properties
955     *
956     * @param array Event A
957     * @param array Event B
958     * @return array List of differing task properties
959     */
960    public static function task_diff($a, $b)
961    {
962        $diff   = array();
963        $ignore = array('changed' => 1, 'attachments' => 1);
964
965        foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) {
966            if (!$ignore[$key] && $a[$key] != $b[$key])
967                $diff[] = $key;
968        }
969
970        // only compare number of attachments
971        if (count($a['attachments']) != count($b['attachments']))
972            $diff[] = 'attachments';
973
974        return $diff;
975    }
976
977    /**
978     * Dispatcher for tasklist actions initiated by the client
979     */
980    public function tasklist_action()
981    {
982        $action  = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
983        $list    = rcube_utils::get_input_value('l', rcube_utils::INPUT_GPC, true);
984        $success = false;
985
986        unset($list['_token']);
987
988        if (isset($list['showalarms'])) {
989            $list['showalarms'] = intval($list['showalarms']);
990        }
991
992        switch ($action) {
993        case 'form-new':
994        case 'form-edit':
995            $this->load_ui();
996            echo $this->ui->tasklist_editform($action, $list);
997            exit;
998
999        case 'new':
1000            $list += array('showalarms' => true, 'active' => true, 'editable' => true);
1001            if ($insert_id = $this->driver->create_list($list)) {
1002                $list['id'] = $insert_id;
1003                if (!$list['_reload']) {
1004                    $this->load_ui();
1005                    $list['html'] = $this->ui->tasklist_list_item($insert_id, $list, $jsenv);
1006                    $list += (array)$jsenv[$insert_id];
1007                }
1008                $this->rc->output->command('plugin.insert_tasklist', $list);
1009                $success = true;
1010            }
1011            break;
1012
1013        case 'edit':
1014            if ($newid = $this->driver->edit_list($list)) {
1015                $list['oldid'] = $list['id'];
1016                $list['id'] = $newid;
1017                $this->rc->output->command('plugin.update_tasklist', $list);
1018                $success = true;
1019            }
1020            break;
1021
1022        case 'subscribe':
1023            $success = $this->driver->subscribe_list($list);
1024            break;
1025
1026        case 'delete':
1027            if (($success = $this->driver->delete_list($list)))
1028                $this->rc->output->command('plugin.destroy_tasklist', $list);
1029            break;
1030
1031        case 'search':
1032            $this->load_ui();
1033            $results = array();
1034            $query   = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
1035            $source  = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
1036
1037            foreach ((array)$this->driver->search_lists($query, $source) as $id => $prop) {
1038                $editname = $prop['editname'];
1039                unset($prop['editname']);  // force full name to be displayed
1040                $prop['active'] = false;
1041
1042                // let the UI generate HTML and CSS representation for this calendar
1043                $html = $this->ui->tasklist_list_item($id, $prop, $jsenv);
1044                $prop += (array)$jsenv[$id];
1045                $prop['editname'] = $editname;
1046                $prop['html'] = $html;
1047
1048                $results[] = $prop;
1049            }
1050            // report more results available
1051            if ($this->driver->search_more_results) {
1052                $this->rc->output->show_message('autocompletemore', 'notice');
1053            }
1054
1055            $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC));
1056            return;
1057        }
1058
1059        if ($success)
1060            $this->rc->output->show_message('successfullysaved', 'confirmation');
1061        else
1062            $this->rc->output->show_message('tasklist.errorsaving', 'error');
1063
1064        $this->rc->output->command('plugin.unlock_saving');
1065    }
1066
1067    /**
1068     * Get counts for active tasks divided into different selectors
1069     */
1070    public function fetch_counts()
1071    {
1072        if (isset($_REQUEST['lists'])) {
1073            $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);
1074        }
1075        else {
1076            foreach ($this->driver->get_lists() as $list) {
1077                if ($list['active'])
1078                    $lists[] = $list['id'];
1079            }
1080        }
1081        $counts = $this->driver->count_tasks($lists);
1082        $this->rc->output->command('plugin.update_counts', $counts);
1083    }
1084
1085    /**
1086     * Adjust the cached counts after changing a task
1087     */
1088    public function update_counts($oldrec, $newrec)
1089    {
1090        // rebuild counts until this function is finally implemented
1091        $this->fetch_counts();
1092
1093        // $this->rc->output->command('plugin.update_counts', $counts);
1094    }
1095
1096    /**
1097     *
1098     */
1099    public function fetch_tasks()
1100    {
1101        $mask   = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC));
1102        $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
1103        $lists  = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);
1104        $filter = array('mask' => $mask, 'search' => $search);
1105
1106        $data = $this->tasks_data($this->driver->list_tasks($filter, $lists));
1107
1108        $this->rc->output->command('plugin.data_ready', array(
1109                'filter' => $mask,
1110                'lists'  => $lists,
1111                'search' => $search,
1112                'data'   => $data,
1113                'tags'   => $this->driver->get_tags(),
1114        ));
1115    }
1116
1117    /**
1118     * Handler for printing calendars
1119     */
1120    public function print_tasks()
1121    {
1122        // Add CSS stylesheets to the page header
1123        $skin_path = $this->local_skin_path();
1124
1125        $this->include_stylesheet($skin_path . '/print.css');
1126        $this->include_script('tasklist.js');
1127
1128        $this->rc->output->add_handlers(array(
1129            'plugin.tasklist_print' => array($this, 'print_tasks_list'),
1130        ));
1131
1132        $this->rc->output->set_pagetitle($this->gettext('print'));
1133        $this->rc->output->send('tasklist.print');
1134    }
1135
1136    /**
1137     * Handler for printing calendars
1138     */
1139    public function print_tasks_list($attrib)
1140    {
1141        $mask   = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC));
1142        $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
1143        $lists  = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);
1144        $filter = array('mask' => $mask, 'search' => $search);
1145
1146        $data = $this->tasks_data($this->driver->list_tasks($filter, $lists));
1147
1148        // we'll build the tasks table in javascript on page load
1149        // where we have sorting methods, etc.
1150        $this->rc->output->set_env('tasks', $data);
1151        $this->rc->output->set_env('filtermask', $mask);
1152
1153        return $this->ui->tasks_resultview($attrib);
1154    }
1155
1156    /**
1157     * Prepare and sort the given task records to be sent to the client
1158     */
1159    private function tasks_data($records)
1160    {
1161        $data = $this->task_tree = $this->task_titles = array();
1162
1163        foreach ($records as $rec) {
1164            if ($rec['parent_id']) {
1165                $this->task_tree[$rec['id']] = $rec['parent_id'];
1166            }
1167
1168            $this->encode_task($rec);
1169
1170            $data[] = $rec;
1171        }
1172
1173        // assign hierarchy level indicators for later sorting
1174        array_walk($data, array($this, 'task_walk_tree'));
1175
1176        return $data;
1177    }
1178
1179    /**
1180     * Prepare the given task record before sending it to the client
1181     */
1182    private function encode_task(&$rec)
1183    {
1184        $rec['mask'] = $this->filter_mask($rec);
1185        $rec['flagged'] = intval($rec['flagged']);
1186        $rec['complete'] = floatval($rec['complete']);
1187
1188        if (is_object($rec['created'])) {
1189            $rec['created_'] = $this->rc->format_date($rec['created']);
1190            $rec['created'] = $rec['created']->format('U');
1191        }
1192        if (is_object($rec['changed'])) {
1193            $rec['changed_'] = $this->rc->format_date($rec['changed']);
1194            $rec['changed'] = $rec['changed']->format('U');
1195        }
1196        else {
1197            $rec['changed'] = null;
1198        }
1199
1200        if ($rec['date']) {
1201            try {
1202                $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone);
1203                $rec['datetime'] = intval($date->format('U'));
1204                $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d'));
1205                $rec['_hasdate'] = 1;
1206            }
1207            catch (Exception $e) {
1208                $rec['date'] = $rec['datetime'] = null;
1209            }
1210        }
1211        else {
1212            $rec['date'] = $rec['datetime'] = null;
1213            $rec['_hasdate'] = 0;
1214        }
1215
1216        if ($rec['startdate']) {
1217            try {
1218                $date = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone);
1219                $rec['startdatetime'] = intval($date->format('U'));
1220                $rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d'));
1221            }
1222            catch (Exception $e) {
1223                $rec['startdate'] = $rec['startdatetime'] = null;
1224            }
1225        }
1226
1227        if ($rec['valarms']) {
1228            $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']);
1229            $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']);
1230        }
1231
1232        if ($rec['recurrence']) {
1233            $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']);
1234            $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], $rec['time'] || $rec['starttime']);
1235        }
1236
1237        foreach ((array)$rec['attachments'] as $k => $attachment) {
1238            $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']);
1239        }
1240
1241        // convert link URIs references into structs
1242        if (array_key_exists('links', $rec)) {
1243            foreach ((array) $rec['links'] as $i => $link) {
1244                if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link, 'task'))) {
1245                    $rec['links'][$i] = $msgref;
1246                }
1247            }
1248        }
1249
1250        // Convert HTML description into plain text
1251        if ($this->is_html($rec)) {
1252            $h2t = new rcube_html2text($rec['description'], false, true, 0);
1253            $rec['description'] = $h2t->get_text();
1254        }
1255
1256        if (!is_array($rec['tags']))
1257            $rec['tags'] = (array)$rec['tags'];
1258        sort($rec['tags'], SORT_LOCALE_STRING);
1259
1260        if (in_array($rec['id'], $this->collapsed_tasks))
1261          $rec['collapsed'] = true;
1262
1263        if (empty($rec['parent_id']))
1264            $rec['parent_id'] = null;
1265
1266        $this->task_titles[$rec['id']] = $rec['title'];
1267    }
1268
1269    /**
1270     * Determine whether the given task description is HTML formatted
1271     */
1272    private function is_html($task)
1273    {
1274        // check for opening and closing <html> or <body> tags
1275        return (preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '</'.$m[1].'>') > 0);
1276    }
1277
1278    /**
1279     * Callback function for array_walk over all tasks.
1280     * Sets tree depth and parent titles
1281     */
1282    private function task_walk_tree(&$rec)
1283    {
1284        $rec['_depth'] = 0;
1285        $parent_titles = array();
1286        $parent_id = $this->task_tree[$rec['id']];
1287        while ($parent_id) {
1288            $rec['_depth']++;
1289            array_unshift($parent_titles, $this->task_titles[$parent_id]);
1290            $parent_id = $this->task_tree[$parent_id];
1291        }
1292
1293        if (count($parent_titles)) {
1294            $rec['parent_title'] = join(' » ', array_filter($parent_titles));
1295        }
1296    }
1297
1298    /**
1299     * Compute the filter mask of the given task
1300     *
1301     * @param array Hash array with Task record properties
1302     * @return int Filter mask
1303     */
1304    public function filter_mask($rec)
1305    {
1306        static $today, $today_date, $tomorrow, $weeklimit;
1307
1308        if (!$today) {
1309            $today_date    = new DateTime('now', $this->timezone);
1310            $today         = $today_date->format('Y-m-d');
1311            $tomorrow_date = new DateTime('now + 1 day', $this->timezone);
1312            $tomorrow      = $tomorrow_date->format('Y-m-d');
1313
1314            // In Kolab-mode we hide "Next 7 days" filter, which means
1315            // "Later" should catch tasks with date after tomorrow (#5353)
1316            if ($this->rc->output->get_env('tasklist_driver') == 'kolab') {
1317                $weeklimit = $tomorrow;
1318            }
1319            else {
1320                $week_date = new DateTime('now + 7 days', $this->timezone);
1321                $weeklimit = $week_date->format('Y-m-d');
1322            }
1323        }
1324
1325        $mask    = 0;
1326        $start   = $rec['startdate'] ?: '1900-00-00';
1327        $duedate = $rec['date'] ?: '3000-00-00';
1328
1329        if ($rec['flagged'])
1330            $mask |= self::FILTER_MASK_FLAGGED;
1331        if ($this->driver->is_complete($rec))
1332            $mask |= self::FILTER_MASK_COMPLETE;
1333
1334        if (empty($rec['date']))
1335            $mask |= self::FILTER_MASK_NODATE;
1336        else if ($rec['date'] < $today)
1337            $mask |= self::FILTER_MASK_OVERDUE;
1338
1339        if (empty($rec['recurrence']) || $duedate < $today || $start > $weeklimit) {
1340            if ($duedate <= $today || ($rec['startdate'] && $start <= $today))
1341                $mask |= self::FILTER_MASK_TODAY;
1342            else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow))
1343                $mask |= self::FILTER_MASK_TOMORROW;
1344            else if (($start > $tomorrow && $start <= $weeklimit) || ($duedate > $tomorrow && $duedate <= $weeklimit))
1345                $mask |= self::FILTER_MASK_WEEK;
1346            else if ($start > $weeklimit || $duedate > $weeklimit)
1347                $mask |= self::FILTER_MASK_LATER;
1348        }
1349        else if ($rec['startdate'] || $rec['date']) {
1350            $date = new DateTime($rec['startdate'] ?: $rec['date'], $this->timezone);
1351
1352            // set safe recurrence start
1353            while ($date->format('Y-m-d') >= $today) {
1354                switch ($rec['recurrence']['FREQ']) {
1355                    case 'DAILY':
1356                        $date = clone $today_date;
1357                        $date->sub(new DateInterval('P1D'));
1358                        break;
1359                    case 'WEEKLY': $date->sub(new DateInterval('P7D')); break;
1360                    case 'MONTHLY': $date->sub(new DateInterval('P1M')); break;
1361                    case 'YEARLY': $date->sub(new DateInterval('P1Y')); break;
1362                    default; break 2;
1363                }
1364            }
1365
1366            $date->_dateonly = true;
1367
1368            $engine = libcalendaring::get_recurrence();
1369            $engine->init($rec['recurrence'], $date);
1370
1371            // check task occurrences (stop next week)
1372            // FIXME: is there a faster way of doing this?
1373            while ($date = $engine->next()) {
1374                $date = $date->format('Y-m-d');
1375
1376                // break iteration asap
1377                if ($date > $duedate || ($mask & self::FILTER_MASK_LATER)) {
1378                    break;
1379                }
1380
1381                if ($date == $today) {
1382                    $mask |= self::FILTER_MASK_TODAY;
1383                }
1384                else if ($date == $tomorrow) {
1385                    $mask |= self::FILTER_MASK_TOMORROW;
1386                }
1387                else if ($date > $tomorrow && $date <= $weeklimit) {
1388                    $mask |= self::FILTER_MASK_WEEK;
1389                }
1390                else if ($date > $weeklimit) {
1391                    $mask |= self::FILTER_MASK_LATER;
1392                    break;
1393                }
1394            }
1395        }
1396
1397        // add masks for assigned tasks
1398        if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false)
1399            $mask |= self::FILTER_MASK_ASSIGNED;
1400        else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false)
1401            $mask |= self::FILTER_MASK_MYTASKS;
1402
1403        return $mask;
1404    }
1405
1406    /**
1407     * Determine whether the current user is an attendee of the given task
1408     */
1409    public function is_attendee($task)
1410    {
1411        $emails = $this->lib->get_user_emails();
1412        foreach ((array)$task['attendees'] as $i => $attendee) {
1413            if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
1414                return $i;
1415            }
1416        }
1417
1418        return false;
1419    }
1420
1421    /**
1422     * Determine whether the current user is the organizer of the given task
1423     */
1424    public function is_organizer($task)
1425    {
1426        $emails = $this->lib->get_user_emails();
1427        return (empty($task['organizer']) || in_array(strtolower($task['organizer']['email']), $emails));
1428    }
1429
1430
1431    /*******  UI functions  ********/
1432
1433    /**
1434     * Render main view of the tasklist task
1435     */
1436    public function tasklist_view()
1437    {
1438        $this->ui->init();
1439        $this->ui->init_templates();
1440
1441        // set autocompletion env
1442        $this->rc->output->set_env('autocomplete_threads', (int)$this->rc->config->get('autocomplete_threads', 0));
1443        $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15));
1444        $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length'));
1445        $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'close');
1446
1447        $this->rc->output->set_pagetitle($this->gettext('navtitle'));
1448        $this->rc->output->send('tasklist.mainview');
1449    }
1450
1451    /**
1452     * Handler for keep-alive requests
1453     * This will check for updated data in active lists and sync them to the client
1454     */
1455    public function refresh($attr)
1456    {
1457        // refresh the entire list every 10th time to also sync deleted items
1458        if (rand(0,10) == 10) {
1459            $this->rc->output->command('plugin.reload_data');
1460            return;
1461        }
1462
1463        $filter = array(
1464            'since'  => $attr['last'],
1465            'search' => rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC),
1466            'mask'   => intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)) & self::FILTER_MASK_COMPLETE,
1467        );
1468        $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);;
1469
1470        $updates = $this->driver->list_tasks($filter, $lists);
1471        if (!empty($updates)) {
1472            $this->rc->output->command('plugin.refresh_tasks', $this->tasks_data($updates), true);
1473
1474            // update counts
1475            $counts = $this->driver->count_tasks($lists);
1476            $this->rc->output->command('plugin.update_counts', $counts);
1477        }
1478    }
1479
1480    /**
1481     * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests.
1482     * This will check for pending notifications and pass them to the client
1483     */
1484    public function pending_alarms($p)
1485    {
1486        $this->load_driver();
1487        if ($alarms = $this->driver->pending_alarms($p['time'] ?: time())) {
1488            foreach ($alarms as $alarm) {
1489                // encode alarm object to suit the expectations of the calendaring code
1490                if ($alarm['date'])
1491                    $alarm['start'] = new DateTime($alarm['date'].' '.$alarm['time'], $this->timezone);
1492
1493                $alarm['id'] = 'task:' . $alarm['id'];  // prefix ID with task:
1494                $alarm['allday'] = empty($alarm['time']) ? 1 : 0;
1495                $p['alarms'][] = $alarm;
1496            }
1497        }
1498
1499        return $p;
1500    }
1501
1502    /**
1503     * Handler for alarm dismiss hook triggered by the calendar module
1504     */
1505    public function dismiss_alarms($p)
1506    {
1507        $this->load_driver();
1508        foreach ((array)$p['ids'] as $id) {
1509            if (strpos($id, 'task:') === 0)
1510                $p['success'] |= $this->driver->dismiss_alarm(substr($id, 5), $p['snooze']);
1511        }
1512
1513        return $p;
1514    }
1515
1516    /**
1517     * Handler for importing .ics files
1518     */
1519    function import_tasks()
1520    {
1521        // Upload progress update
1522        if (!empty($_GET['_progress'])) {
1523            $this->rc->upload_progress();
1524        }
1525
1526        @set_time_limit(0);
1527
1528        // process uploaded file if there is no error
1529        $err = $_FILES['_data']['error'];
1530
1531        if (!$err && $_FILES['_data']['tmp_name']) {
1532            $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
1533            $lists  = $this->driver->get_lists();
1534            $list   = $lists[$source] ?: $this->get_default_tasklist();
1535            $source = $list['id'];
1536
1537            // extract zip file
1538            if ($_FILES['_data']['type'] == 'application/zip') {
1539                $count = 0;
1540                if (class_exists('ZipArchive', false)) {
1541                    $zip = new ZipArchive();
1542                    if ($zip->open($_FILES['_data']['tmp_name'])) {
1543                        $randname = uniqid('zip-' . session_id(), true);
1544                        $tmpdir   = slashify($this->rc->config->get('temp_dir', sys_get_temp_dir())) . $randname;
1545                        mkdir($tmpdir, 0700);
1546
1547                        // extract each ical file from the archive and import it
1548                        for ($i = 0; $i < $zip->numFiles; $i++) {
1549                            $filename = $zip->getNameIndex($i);
1550                            if (preg_match('/\.ics$/i', $filename)) {
1551                                $tmpfile = $tmpdir . '/' . basename($filename);
1552                                if (copy('zip://' . $_FILES['_data']['tmp_name'] . '#'.$filename, $tmpfile)) {
1553                                    $count += $this->import_from_file($tmpfile, $source, $errors);
1554                                    unlink($tmpfile);
1555                                }
1556                            }
1557                        }
1558
1559                        rmdir($tmpdir);
1560                        $zip->close();
1561                    }
1562                    else {
1563                        $errors = 1;
1564                        $msg = 'Failed to open zip file.';
1565                    }
1566                }
1567                else {
1568                    $errors = 1;
1569                    $msg = 'Zip files are not supported for import.';
1570                }
1571            }
1572            else {
1573                // attempt to import the uploaded file directly
1574                $count = $this->import_from_file($_FILES['_data']['tmp_name'], $source, $errors);
1575            }
1576
1577            if ($count) {
1578                $this->rc->output->command('display_message', $this->gettext(array('name' => 'importsuccess', 'vars' => array('nr' => $count))), 'confirmation');
1579                $this->rc->output->command('plugin.import_success', array('source' => $source, 'refetch' => true));
1580            }
1581            else if (!$errors) {
1582                $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice');
1583                $this->rc->output->command('plugin.import_success', array('source' => $source));
1584            }
1585            else {
1586                $this->rc->output->command('plugin.import_error', array('message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : '')));
1587            }
1588        }
1589        else {
1590            if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) {
1591                $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array(
1592                    'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize'))))));
1593            }
1594            else {
1595                $msg = $this->rc->gettext('fileuploaderror');
1596            }
1597
1598            $this->rc->output->command('plugin.import_error', array('message' => $msg));
1599        }
1600
1601        $this->rc->output->send('iframe');
1602    }
1603
1604    /**
1605     * Helper function to parse and import a single .ics file
1606     */
1607    private function import_from_file($filepath, $source, &$errors)
1608    {
1609        $user_email = $this->rc->user->get_username();
1610        $ical       = $this->get_ical();
1611        $errors     = !$ical->fopen($filepath);
1612        $count      = $i = 0;
1613
1614        foreach ($ical as $task) {
1615            // keep the browser connection alive on long import jobs
1616            if (++$i > 100 && $i % 100 == 0) {
1617                echo "<!-- -->";
1618                ob_flush();
1619            }
1620
1621            if ($task['_type'] == 'task') {
1622                $task['list'] = $source;
1623
1624                if ($this->driver->create_task($task)) {
1625                    $count++;
1626                }
1627                else {
1628                    $errors++;
1629                }
1630            }
1631        }
1632
1633        return $count;
1634    }
1635
1636    /**
1637     * Construct the ics file for exporting tasks to iCalendar format
1638     */
1639    function export_tasks()
1640    {
1641        $source      = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
1642        $task_id     = rcube_utils::get_input_value('id', rcube_utils::INPUT_GPC);
1643        $attachments = (bool) rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GPC);
1644
1645        $this->load_driver();
1646
1647        $browser = new rcube_browser;
1648        $lists   = $this->driver->get_lists();
1649        $tasks   = array();
1650        $filter  = array();
1651
1652        // get message UIDs for filter
1653        if ($source && ($list = $lists[$source])) {
1654            $filename = html_entity_decode($list['name']) ?: $sorce;
1655            $filter   = array($source => true);
1656        }
1657        else if ($task_id) {
1658            $filename = 'tasks';
1659            foreach (explode(',', $task_id) as $id) {
1660                list($list_id, $task_id) = explode(':', $id, 2);
1661                if ($list_id && $task_id) {
1662                    $filter[$list_id][] = $task_id;
1663                }
1664            }
1665        }
1666
1667        // Get tasks
1668        foreach ($filter as $list_id => $uids) {
1669            $_filter = is_array($uids) ? array('uid' => $uids) : null;
1670            $_tasks  = $this->driver->list_tasks($_filter, $list_id);
1671            if (!empty($_tasks)) {
1672                $tasks = array_merge($tasks, $_tasks);
1673            }
1674        }
1675
1676        // Set file name
1677        if ($source && count($tasks) == 1) {
1678            $filename = $tasks[0]['title'] ?: 'task';
1679        }
1680        $filename .= '.ics';
1681        $filename = $browser->ie ? rawurlencode($filename) : addcslashes($filename, '"');
1682
1683        $tasks = array_map(array($this, 'to_libcal'), $tasks);
1684
1685        // Give plugins a possibility to implement other output formats or modify the result
1686        $plugin = $this->rc->plugins->exec_hook('tasks_export', array(
1687                'result'      => $tasks,
1688                'attachments' => $attachments,
1689                'filename'    => $filename,
1690                'plugin'      => $this,
1691        ));
1692
1693        if ($plugin['abort']) {
1694            exit;
1695        }
1696
1697        $this->rc->output->nocacheing_headers();
1698
1699        // don't kill the connection if download takes more than 30 sec.
1700        @set_time_limit(0);
1701        header("Content-Type: text/calendar");
1702        header("Content-Disposition: inline; filename=\"". $plugin['filename'] ."\"");
1703
1704        $this->get_ical()->export($plugin['result'], '', true,
1705            $plugins['attachments'] ? array($this->driver, 'get_attachment_body') : null);
1706        exit;
1707    }
1708
1709
1710    /******* Attachment handling  *******/
1711
1712    /**
1713     * Handler for attachments upload
1714    */
1715    public function attachment_upload()
1716    {
1717        $handler = new kolab_attachments_handler();
1718        $handler->attachment_upload(self::SESSION_KEY);
1719    }
1720
1721    /**
1722     * Handler for attachments download/displaying
1723     */
1724    public function attachment_get()
1725    {
1726        $handler = new kolab_attachments_handler();
1727
1728        // show loading page
1729        if (!empty($_GET['_preload'])) {
1730            return $handler->attachment_loading_page();
1731        }
1732
1733        $task = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC);
1734        $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC);
1735        $id   = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
1736        $rev  = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC);
1737
1738        $task       = array('id' => $task, 'list' => $list, 'rev' => $rev);
1739        $attachment = $this->driver->get_attachment($id, $task);
1740
1741        // show part page
1742        if (!empty($_GET['_frame'])) {
1743            $handler->attachment_page($attachment);
1744        }
1745        // deliver attachment content
1746        else if ($attachment) {
1747            $attachment['body'] = $this->driver->get_attachment_body($id, $task);
1748            $handler->attachment_get($attachment);
1749        }
1750
1751        // if we arrive here, the requested part was not found
1752        header('HTTP/1.1 404 Not Found');
1753        exit;
1754    }
1755
1756
1757    /*******  Email related function *******/
1758
1759    public function mail_message2task()
1760    {
1761        $this->load_ui();
1762        $this->ui->init();
1763        $this->ui->init_templates();
1764        $this->ui->tasklists();
1765
1766        $uid  = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET);
1767        $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_GET);
1768        $task = array();
1769
1770        $imap    = $this->rc->get_storage();
1771        $message = new rcube_message($uid, $mbox);
1772
1773        if ($message->headers) {
1774            $task['title']       = trim($message->subject);
1775            $task['description'] = trim($message->first_text_part());
1776            $task['id']          = -$uid;
1777
1778            $this->load_driver();
1779
1780            // add a reference to the email message
1781            if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) {
1782                $task['links'] = array($msgref);
1783            }
1784            // copy mail attachments to task
1785            else if ($message->attachments && $this->driver->attachments) {
1786                if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $task['id']) {
1787                    $_SESSION[self::SESSION_KEY] = array(
1788                        'id'          => $task['id'],
1789                        'attachments' => array(),
1790                    );
1791                }
1792
1793                foreach ((array)$message->attachments as $part) {
1794                    $attachment = array(
1795                        'data'     => $imap->get_message_part($uid, $part->mime_id, $part),
1796                        'size'     => $part->size,
1797                        'name'     => $part->filename,
1798                        'mimetype' => $part->mimetype,
1799                        'group'    => $task['id'],
1800                    );
1801
1802                    $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment);
1803
1804                    if ($attachment['status'] && !$attachment['abort']) {
1805                        $id = $attachment['id'];
1806                        $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']);
1807
1808                        // store new attachment in session
1809                        unset($attachment['status'], $attachment['abort'], $attachment['data']);
1810                        $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment;
1811
1812                        $attachment['id'] = 'rcmfile' . $attachment['id'];  // add prefix to consider it 'new'
1813                        $task['attachments'][] = $attachment;
1814                    }
1815                }
1816            }
1817
1818            $this->rc->output->set_env('task_prop', $task);
1819        }
1820        else {
1821            $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error');
1822        }
1823
1824        $this->rc->output->send('tasklist.dialog');
1825    }
1826
1827    /**
1828     * Add UI element to copy task invitations or updates to the tasklist
1829     */
1830    public function mail_messagebody_html($p)
1831    {
1832        // load iCalendar functions (if necessary)
1833        if (!empty($this->lib->ical_parts)) {
1834            $this->get_ical();
1835            $this->load_itip();
1836        }
1837
1838        $html = '';
1839        $has_tasks = false;
1840        $ical_objects = $this->lib->get_mail_ical_objects();
1841
1842        // show a box for every task in the file
1843        foreach ($ical_objects as $idx => $task) {
1844            if ($task['_type'] != 'task') {
1845                continue;
1846            }
1847
1848            $has_tasks = true;
1849
1850            // get prepared inline UI for this event object
1851            if ($ical_objects->method) {
1852                $html .= html::div('tasklist-invitebox invitebox boxinformation',
1853                    $this->itip->mail_itip_inline_ui(
1854                        $task,
1855                        $ical_objects->method,
1856                        $ical_objects->mime_id . ':' . $idx,
1857                        'tasks',
1858                        rcube_utils::anytodatetime($ical_objects->message_date)
1859                    )
1860                );
1861            }
1862
1863            // limit listing
1864            if ($idx >= 3) {
1865                break;
1866            }
1867        }
1868
1869        // list linked tasks
1870        $links = array();
1871        foreach ($this->message_tasks as $task) {
1872            $checkbox = new html_checkbox(array(
1873                'name' => 'completed',
1874                'class' => 'complete pretty-checkbox',
1875                'title' => $this->gettext('complete'),
1876                'data-list' => $task['list'],
1877            ));
1878            $complete = $this->driver->is_complete($task);
1879            $links[] = html::tag('li', 'messagetaskref' . ($complete ? ' complete' : ''),
1880                $checkbox->show($complete ? $task['id'] : null, array('value' => $task['id'])) . ' ' .
1881                html::a(array(
1882                    'href' => $this->rc->url(array(
1883                        'task' => 'tasks',
1884                        'list' => $task['list'],
1885                        'id' => $task['id'],
1886                    )),
1887                    'class' => 'messagetasklink',
1888                    'rel' => $task['id'] . '@' . $task['list'],
1889                    'target' => '_blank',
1890                ), rcube::Q($task['title']))
1891            );
1892        }
1893        if (count($links)) {
1894            $html .= html::div('messagetasklinks boxinformation', html::tag('ul', 'tasklist', join("\n", $links)));
1895        }
1896
1897        // prepend iTip/relation boxes to message body
1898        if ($html) {
1899            $this->load_ui();
1900            $this->ui->init();
1901
1902            $p['content'] = $html . $p['content'];
1903
1904            $this->rc->output->add_label('tasklist.savingdata','tasklist.deletetaskconfirm','tasklist.declinedeleteconfirm');
1905        }
1906
1907        // add "Save to tasks" button into attachment menu
1908        if ($has_tasks) {
1909            $this->add_button(array(
1910                'id'         => 'attachmentsavetask',
1911                'name'       => 'attachmentsavetask',
1912                'type'       => 'link',
1913                'wrapper'    => 'li',
1914                'command'    => 'attachment-save-task',
1915                'class'      => 'icon tasklistlink disabled',
1916                'classact'   => 'icon tasklistlink active',
1917                'innerclass' => 'icon taskadd',
1918                'label'      => 'tasklist.savetotasklist',
1919            ), 'attachmentmenu');
1920        }
1921
1922        return $p;
1923    }
1924
1925    /**
1926     * Lookup backend storage and find notes associated with the given message
1927     */
1928    public function mail_message_load($p)
1929    {
1930        if (!$p['object']->headers->others['x-kolab-type']) {
1931            $this->load_driver();
1932            $this->message_tasks = $this->driver->get_message_related_tasks($p['object']->headers, $p['object']->folder);
1933
1934            // sort message tasks by completeness and due date
1935            $driver = $this->driver;
1936            array_walk($this->message_tasks, array($this, 'encode_task'));
1937            usort($this->message_tasks, function($a, $b) use ($driver) {
1938                $a_complete = intval($driver->is_complete($a));
1939                $b_complete = intval($driver->is_complete($b));
1940                $d = $a_complete - $b_complete;
1941                if (!$d) $d = $b['_hasdate'] - $a['_hasdate'];
1942                if (!$d) $d = $a['datetime'] - $b['datetime'];
1943                return $d;
1944            });
1945        }
1946    }
1947
1948    /**
1949     * Load iCalendar functions
1950     */
1951    public function get_ical()
1952    {
1953        if (!$this->ical) {
1954            $this->ical = libcalendaring::get_ical();
1955        }
1956
1957        return $this->ical;
1958    }
1959
1960    /**
1961     * Get properties of the tasklist this user has specified as default
1962     */
1963    public function get_default_tasklist($sensitivity = null, $lists = null)
1964    {
1965        if ($lists === null) {
1966            $lists = $this->driver->get_lists(tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_WRITEABLE);
1967        }
1968
1969        $list = null;
1970
1971        foreach ($lists as $l) {
1972            if ($sensitivity && $l['subtype'] == $sensitivity) {
1973                $list = $l;
1974                break;
1975            }
1976            if ($l['default']) {
1977                $list = $l;
1978            }
1979
1980            if ($l['editable']) {
1981                $first = $l;
1982            }
1983        }
1984
1985        return $list ?: $first;
1986    }
1987
1988    /**
1989     * Import the full payload from a mail message attachment
1990     */
1991    public function mail_import_attachment()
1992    {
1993        $uid     = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST);
1994        $mbox    = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST);
1995        $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST);
1996        $charset = RCUBE_CHARSET;
1997
1998        // establish imap connection
1999        $imap = $this->rc->get_storage();
2000        $imap->set_folder($mbox);
2001
2002        if ($uid && $mime_id) {
2003            $part    = $imap->get_message_part($uid, $mime_id);
2004//            $headers = $imap->get_message_headers($uid);
2005
2006            if ($part->ctype_parameters['charset']) {
2007                $charset = $part->ctype_parameters['charset'];
2008            }
2009
2010            if ($part) {
2011                $tasks = $this->get_ical()->import($part, $charset);
2012            }
2013        }
2014
2015        $success = $existing = 0;
2016
2017        if (!empty($tasks)) {
2018            // find writeable tasklist to store task
2019            $cal_id = !empty($_REQUEST['_list']) ? rcube_utils::get_input_value('_list', rcube_utils::INPUT_POST) : null;
2020            $lists  = $this->driver->get_lists();
2021
2022            foreach ($tasks as $task) {
2023                // save to tasklist
2024                $list   = $lists[$cal_id] ?: $this->get_default_tasklist($task['sensitivity']);
2025                if ($list && $list['editable'] && $task['_type'] == 'task') {
2026                    $task = $this->from_ical($task);
2027                    $task['list'] = $list['id'];
2028
2029                    if (!$this->driver->get_task($task['uid'])) {
2030                        $success += (bool) $this->driver->create_task($task);
2031                    }
2032                    else {
2033                        $existing++;
2034                    }
2035                }
2036            }
2037        }
2038
2039        if ($success) {
2040            $this->rc->output->command('display_message', $this->gettext(array(
2041                'name' => 'importsuccess',
2042                'vars' => array('nr' => $success),
2043            )), 'confirmation');
2044        }
2045        else if ($existing) {
2046            $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning');
2047        }
2048        else {
2049            $this->rc->output->command('display_message', $this->gettext('errorimportingtask'), 'error');
2050        }
2051    }
2052
2053    /**
2054     * Handler for POST request to import an event attached to a mail message
2055     */
2056    public function mail_import_itip()
2057    {
2058        $uid     = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST);
2059        $mbox    = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST);
2060        $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST);
2061        $status  = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST);
2062        $comment = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST);
2063        $delete  = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST));
2064        $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)) || $status == 'needs-action';
2065
2066        $error_msg = $this->gettext('errorimportingtask');
2067        $success   = false;
2068
2069        if ($status == 'delegated') {
2070            $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false);
2071            $delegate  = reset($delegates);
2072
2073            if (empty($delegate) || empty($delegate['mailto'])) {
2074                $this->rc->output->command('display_message', $this->gettext('libcalendaring.delegateinvalidaddress'), 'error');
2075                return;
2076            }
2077        }
2078
2079        // successfully parsed tasks?
2080        if ($task = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'task')) {
2081            $task = $this->from_ical($task);
2082
2083            // forward iTip request to delegatee
2084            if ($delegate) {
2085                $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST);
2086                $itip   = $this->load_itip();
2087
2088                $task['comment'] = $comment;
2089
2090                if ($itip->delegate_to($task, $delegate, !empty($rsvpme))) {
2091                    $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation');
2092                }
2093                else {
2094                    $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
2095                }
2096
2097                unset($task['comment']);
2098            }
2099
2100            $mode = tasklist_driver::FILTER_PERSONAL
2101                | tasklist_driver::FILTER_SHARED
2102                | tasklist_driver::FILTER_WRITEABLE;
2103
2104            // find writeable list to store the task
2105            $list_id = !empty($_REQUEST['_folder']) ? rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST) : null;
2106            $lists   = $this->driver->get_lists($mode);
2107            $list    = $lists[$list_id];
2108            $dontsave = ($_REQUEST['_folder'] === '' && $task['_method'] == 'REQUEST');
2109
2110            // select default list except user explicitly selected 'none'
2111            if (!$list && !$dontsave) {
2112                $list = $this->get_default_tasklist($task['sensitivity'], $lists);
2113            }
2114
2115            $metadata = array(
2116                'uid'      => $task['uid'],
2117                'changed'  => is_object($task['changed']) ? $task['changed']->format('U') : 0,
2118                'sequence' => intval($task['sequence']),
2119                'fallback' => strtoupper($status),
2120                'method'   => $task['_method'],
2121                'task'     => 'tasks',
2122            );
2123
2124            // update my attendee status according to submitted method
2125            if (!empty($status)) {
2126                $organizer = $task['organizer'];
2127                $emails    = $this->lib->get_user_emails();
2128
2129                foreach ($task['attendees'] as $i => $attendee) {
2130                    if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
2131                        $metadata['attendee'] = $attendee['email'];
2132                        $metadata['rsvp']     = $attendee['role'] != 'NON-PARTICIPANT';
2133                        $reply_sender         = $attendee['email'];
2134
2135                        $task['attendees'][$i]['status'] = strtoupper($status);
2136                        if (!in_array($task['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED'))) {
2137                            $task['attendees'][$i]['rsvp'] = false;  // unset RSVP attribute
2138                        }
2139                    }
2140                }
2141
2142                // add attendee with this user's default identity if not listed
2143                if (!$reply_sender) {
2144                    $sender_identity = $this->rc->user->list_emails(true);
2145                    $task['attendees'][] = array(
2146                        'name'   => $sender_identity['name'],
2147                        'email'  => $sender_identity['email'],
2148                        'role'   => 'OPT-PARTICIPANT',
2149                        'status' => strtoupper($status),
2150                    );
2151                    $metadata['attendee'] = $sender_identity['email'];
2152                }
2153            }
2154
2155            // save to tasklist
2156            if ($list && $list['editable']) {
2157                $task['list'] = $list['id'];
2158
2159                // check for existing task with the same UID
2160                $existing = $this->find_task($task['uid'], $mode);
2161
2162                if ($existing) {
2163                    // only update attendee status
2164                    if ($task['_method'] == 'REPLY') {
2165                        // try to identify the attendee using the email sender address
2166                        $existing_attendee = -1;
2167                        $existing_attendee_emails = array();
2168                        foreach ($existing['attendees'] as $i => $attendee) {
2169                            $existing_attendee_emails[] = $attendee['email'];
2170                            if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) {
2171                                $existing_attendee = $i;
2172                            }
2173                        }
2174
2175                        $task_attendee = null;
2176                        foreach ($task['attendees'] as $attendee) {
2177                            if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) {
2178                                $task_attendee        = $attendee;
2179                                $metadata['fallback'] = $attendee['status'];
2180                                $metadata['attendee'] = $attendee['email'];
2181                                $metadata['rsvp']     = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT';
2182                                if ($attendee['status'] != 'DELEGATED') {
2183                                    break;
2184                                }
2185                            }
2186                            // also copy delegate attendee
2187                            else if (!empty($attendee['delegated-from']) &&
2188                                     (stripos($attendee['delegated-from'], $task['_sender']) !== false || stripos($attendee['delegated-from'], $task['_sender_utf']) !== false) &&
2189                                     (!in_array($attendee['email'], $existing_attendee_emails))) {
2190                                $existing['attendees'][] = $attendee;
2191                            }
2192                        }
2193
2194                        // if delegatee has declined, set delegator's RSVP=True
2195                        if ($task_attendee && $task_attendee['status'] == 'DECLINED' && $task_attendee['delegated-from']) {
2196                            foreach ($existing['attendees'] as $i => $attendee) {
2197                                if ($attendee['email'] == $task_attendee['delegated-from']) {
2198                                    $existing['attendees'][$i]['rsvp'] = true;
2199                                    break;
2200                                }
2201                            }
2202                        }
2203
2204                        // found matching attendee entry in both existing and new events
2205                        if ($existing_attendee >= 0 && $task_attendee) {
2206                            $existing['attendees'][$existing_attendee] = $task_attendee;
2207                            $success = $this->driver->edit_task($existing);
2208                        }
2209                        // update the entire attendees block
2210                        else if (($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) && $task_attendee) {
2211                            $existing['attendees'][] = $task_attendee;
2212                            $success = $this->driver->edit_task($existing);
2213                        }
2214                        else {
2215                            $error_msg = $this->gettext('newerversionexists');
2216                        }
2217                    }
2218                    // delete the task when declined
2219                    else if ($status == 'declined' && $delete) {
2220                        $deleted = $this->driver->delete_task($existing, true);
2221                        $success = true;
2222                    }
2223                    // import the (newer) task
2224                    else if ($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) {
2225                        $task['id']   = $existing['id'];
2226                        $task['list'] = $existing['list'];
2227
2228                        // preserve my participant status for regular updates
2229                        if (empty($status)) {
2230                            $this->lib->merge_attendees($task, $existing);
2231                        }
2232
2233                        // set status=CANCELLED on CANCEL messages
2234                        if ($task['_method'] == 'CANCEL') {
2235                            $task['status'] = 'CANCELLED';
2236                        }
2237
2238                        // update attachments list, allow attachments update only on REQUEST (#5342)
2239                        if ($task['_method'] == 'REQUEST') {
2240                            $task['deleted_attachments'] = true;
2241                        }
2242                        else {
2243                            unset($task['attachments']);
2244                        }
2245
2246                        // show me as free when declined (#1670)
2247                        if ($status == 'declined' || $task['status'] == 'CANCELLED') {
2248                            $task['free_busy'] = 'free';
2249                        }
2250
2251                        $success = $this->driver->edit_task($task);
2252                    }
2253                    else if (!empty($status)) {
2254                        $existing['attendees'] = $task['attendees'];
2255                        if ($status == 'declined') { // show me as free when declined (#1670)
2256                            $existing['free_busy'] = 'free';
2257                        }
2258
2259                        $success = $this->driver->edit_event($existing);
2260                    }
2261                    else {
2262                        $error_msg = $this->gettext('newerversionexists');
2263                    }
2264                }
2265                else if (!$existing && ($status != 'declined' || $this->rc->config->get('kolab_invitation_tasklists'))) {
2266                    $success = $this->driver->create_task($task);
2267                }
2268                else if ($status == 'declined') {
2269                    $error_msg = null;
2270                }
2271            }
2272            else if ($status == 'declined' || $dontsave) {
2273                $error_msg = null;
2274            }
2275            else {
2276                $error_msg = $this->gettext('nowritetasklistfound');
2277            }
2278        }
2279
2280        if ($success || $dontsave) {
2281            if ($success) {
2282                $message = $task['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully'));
2283                $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('list' => $list['name']))), 'confirmation');
2284            }
2285
2286            $metadata['rsvp']         = intval($metadata['rsvp']);
2287            $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', 0);
2288
2289            $this->rc->output->command('plugin.itip_message_processed', $metadata);
2290            $error_msg = null;
2291        }
2292        else if ($error_msg) {
2293            $this->rc->output->command('display_message', $error_msg, 'error');
2294        }
2295
2296        // send iTip reply
2297        if ($task['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) {
2298            $task['comment'] = $comment;
2299            $itip = $this->load_itip();
2300            $itip->set_sender_email($reply_sender);
2301
2302            if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status))
2303                $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ?: $organizer['email']))), 'confirmation');
2304            else
2305                $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
2306        }
2307
2308        $this->rc->output->send();
2309    }
2310
2311
2312    /****  Task invitation plugin hooks ****/
2313
2314    /**
2315     * Handler for task/itip-delegate requests
2316     */
2317    function mail_itip_delegate()
2318    {
2319        // forward request to mail_import_itip() with the right status
2320        $_POST['_status'] = $_REQUEST['_status'] = 'delegated';
2321        $this->mail_import_itip();
2322    }
2323
2324    /**
2325     * Find a task in user tasklists
2326     */
2327    protected function find_task($task, &$mode)
2328    {
2329        $this->load_driver();
2330
2331        // We search for writeable folders in personal namespace by default
2332        $mode   = tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_PERSONAL;
2333        $result = $this->driver->get_task($task, $mode);
2334
2335        // ... now check shared folders if not found
2336        if (!$result) {
2337            $result = $this->driver->get_task($task, tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_SHARED);
2338            if ($result) {
2339                $mode |= tasklist_driver::FILTER_SHARED;
2340            }
2341        }
2342
2343        return $result;
2344    }
2345
2346    /**
2347     * Handler for task/itip-status requests
2348     */
2349    public function task_itip_status()
2350    {
2351        $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true);
2352
2353        // find local copy of the referenced task
2354        $existing  = $this->find_task($data, $mode);
2355        $is_shared = $mode & tasklist_driver::FILTER_SHARED;
2356        $itip      = $this->load_itip();
2357        $response  = $itip->get_itip_status($data, $existing);
2358
2359        // get a list of writeable lists to save new tasks to
2360        if ((!$existing || $is_shared) && $response['action'] == 'rsvp' || $response['action'] == 'import') {
2361            $lists  = $this->driver->get_lists($mode);
2362            $select = new html_select(array('name' => 'tasklist', 'id' => 'itip-saveto', 'is_escaped' => true, 'class' => 'form-control'));
2363            $select->add('--', '');
2364
2365            foreach ($lists as $list) {
2366                if ($list['editable']) {
2367                    $select->add($list['name'], $list['id']);
2368                }
2369            }
2370        }
2371
2372        if ($select) {
2373            $default_list = $this->get_default_tasklist($data['sensitivity'], $lists);
2374            $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . '&nbsp;' .
2375                $select->show($is_shared ? $existing['list'] : $default_list['id']));
2376        }
2377
2378        $this->rc->output->command('plugin.update_itip_object_status', $response);
2379    }
2380
2381    /**
2382     * Handler for task/itip-remove requests
2383     */
2384    public function task_itip_remove()
2385    {
2386        $success = false;
2387        $uid     = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST);
2388
2389        // search for event if only UID is given
2390        if ($task = $this->driver->get_task($uid)) {
2391            $success = $this->driver->delete_task($task, true);
2392        }
2393
2394        if ($success) {
2395            $this->rc->output->show_message('tasklist.successremoval', 'confirmation');
2396        }
2397        else {
2398            $this->rc->output->show_message('tasklist.errorsaving', 'error');
2399        }
2400    }
2401
2402
2403    /*******  Utility functions  *******/
2404
2405    /**
2406     * Generate a unique identifier for an event
2407     */
2408    public function generate_uid()
2409    {
2410      return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16));
2411    }
2412
2413    /**
2414     * Map task properties for ical exprort using libcalendaring
2415     */
2416    public function to_libcal($task)
2417    {
2418        $object = $task;
2419        $object['_type'] = 'task';
2420        $object['categories'] = (array)$task['tags'];
2421
2422        // convert to datetime objects
2423        if (!empty($task['date'])) {
2424            $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->timezone);
2425            if (empty($task['time']))
2426                $object['due']->_dateonly = true;
2427            unset($object['date']);
2428        }
2429
2430        if (!empty($task['startdate'])) {
2431            $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->timezone);
2432            if (empty($task['starttime']))
2433                $object['start']->_dateonly = true;
2434            unset($object['startdate']);
2435        }
2436
2437        $object['complete'] = $task['complete'] * 100;
2438        if ($task['complete'] == 1.0 && empty($task['complete'])) {
2439            $object['status'] = 'COMPLETED';
2440        }
2441
2442        if ($task['flagged']) {
2443            $object['priority'] = 1;
2444        }
2445        else if (!$task['priority']) {
2446            $object['priority'] = 0;
2447        }
2448
2449        return $object;
2450    }
2451
2452    /**
2453     * Convert task properties from ical parser to the internal format
2454     */
2455    public function from_ical($vtodo)
2456    {
2457        $task = $vtodo;
2458
2459        $task['tags'] = array_filter((array)$vtodo['categories']);
2460        $task['flagged'] = $vtodo['priority'] == 1;
2461        $task['complete'] = floatval($vtodo['complete'] / 100);
2462
2463        // convert from DateTime to internal date format
2464        if (is_a($vtodo['due'], 'DateTime')) {
2465            $due = $this->lib->adjust_timezone($vtodo['due']);
2466            $task['date'] = $due->format('Y-m-d');
2467            if (!$vtodo['due']->_dateonly)
2468                $task['time'] = $due->format('H:i');
2469        }
2470        // convert from DateTime to internal date format
2471        if (is_a($vtodo['start'], 'DateTime')) {
2472            $start = $this->lib->adjust_timezone($vtodo['start']);
2473            $task['startdate'] = $start->format('Y-m-d');
2474            if (!$vtodo['start']->_dateonly)
2475                $task['starttime'] = $start->format('H:i');
2476        }
2477        if (is_a($vtodo['dtstamp'], 'DateTime')) {
2478            $task['changed'] = $vtodo['dtstamp'];
2479        }
2480
2481        unset($task['categories'], $task['due'], $task['start'], $task['dtstamp']);
2482
2483        return $task;
2484    }
2485
2486    /**
2487     * Handler for user_delete plugin hook
2488     */
2489    public function user_delete($args)
2490    {
2491       $this->load_driver();
2492       return $this->driver->user_delete($args);
2493    }
2494
2495
2496    /**
2497     * Magic getter for public access to protected members
2498     */
2499    public function __get($name)
2500    {
2501        switch ($name) {
2502            case 'ical':
2503                return $this->get_ical();
2504
2505            case 'itip':
2506                return $this->load_itip();
2507
2508            case 'driver':
2509                $this->load_driver();
2510                return $this->driver;
2511        }
2512
2513        return null;
2514    }
2515}
2516