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') . ' ' . 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