diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index 6c23741b..ae1c4cea 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -2341,6 +2341,7 @@ class calendar extends rcube_plugin } $html = ''; + $has_events = false; foreach ($this->ics_parts as $mime_id) { $part = $this->message->mime_parts[$mime_id]; $charset = $part->ctype_parameters['charset'] ? $part->ctype_parameters['charset'] : RCMAIL_CHARSET; @@ -2356,16 +2357,20 @@ class calendar extends rcube_plugin if ($event['_type'] != 'event') // skip non-event objects (#2928) continue; + $has_events = true; + // get prepared inline UI for this event object - $html .= html::div('calendar-invitebox', - $this->itip->mail_itip_inline_ui( - $event, - $this->ical->method, - $mime_id.':'.$idx, - 'calendar', - rcube_utils::anytodatetime($this->message->headers->date) - ) - ); + if ($this->ical->method) { + $html .= html::div('calendar-invitebox', + $this->itip->mail_itip_inline_ui( + $event, + $this->ical->method, + $mime_id.':'.$idx, + 'calendar', + rcube_utils::anytodatetime($this->message->headers->date) + ) + ); + } // limit listing if ($idx >= 3) @@ -2381,7 +2386,7 @@ class calendar extends rcube_plugin } // add "Save to calendar" button into attachment menu - if (!empty($this->ics_parts)) { + if ($has_events) { $this->add_button(array( 'id' => 'attachmentsavecal', 'name' => 'attachmentsavecal', diff --git a/plugins/libcalendaring/lib/libcalendaring_itip.php b/plugins/libcalendaring/lib/libcalendaring_itip.php index 074f87f6..93bdfee9 100644 --- a/plugins/libcalendaring/lib/libcalendaring_itip.php +++ b/plugins/libcalendaring/lib/libcalendaring_itip.php @@ -60,6 +60,11 @@ class libcalendaring_itip $this->rsvp_status = array_merge($this->rsvp_actions, array('delegated')); } + public function set_rsvp_status($status) + { + $this->rsvp_status = $status; + } + /** * Wrapper for rcube_plugin::gettext() * Checking for a label in different domains @@ -213,6 +218,14 @@ class libcalendaring_itip $event['attendees'] = $reply_attendees; } } + // set RSVP=TRUE for every attendee if not set + else if ($method == 'REQUEST') { + foreach ($event['attendees'] as $i => $attendee) { + if (!isset($attendee['rsvp'])) { + $event['attendees'][$i]['rsvp']= true; + } + } + } // compose multipart message using PEAR:Mail_Mime $message = new Mail_mime("\r\n"); @@ -236,9 +249,10 @@ class libcalendaring_itip $message->headers($headers); // attach ics file for this event - $ical = $this->plugin->get_ical(); + $ical = libcalendaring::get_ical(); $ics = $ical->export(array($event), $method, false, $method == 'REQUEST' && $this->plugin->driver ? array($this->plugin->driver, 'get_attachment_body') : false); - $message->addAttachment($ics, 'text/calendar', 'event.ics', false, '8bit', '', RCMAIL_CHARSET . "; method=" . $method); + $filename = $event['_type'] == 'task' ? 'todo.ics' : 'event.ics'; + $message->addAttachment($ics, 'text/calendar', $filename, false, '8bit', '', RCMAIL_CHARSET . "; method=" . $method); return $message; } @@ -313,9 +327,10 @@ class libcalendaring_itip $listed = false; foreach ($existing['attendees'] as $attendee) { if ($attendee['role'] != 'ORGANIZER' && strcasecmp($attendee['email'], $event['attendee']) == 0) { - if (in_array($status, array('ACCEPTED','TENTATIVE','DECLINED','DELEGATED'))) { - $html = html::div('rsvp-status ' . strtolower($status), $this->gettext(array( - 'name' => 'attendee'.strtolower($status), + $status_lc = strtolower($status); + if (in_array($status_lc, $this->rsvp_status)) { + $html = html::div('rsvp-status ' . $status_lc, $this->gettext(array( + 'name' => 'attendee' . $status_lc, 'vars' => array( 'delegatedto' => Q($attendee['delegated-to'] ?: '?'), ) @@ -532,15 +547,21 @@ class libcalendaring_itip } /** - * Render event details in a table + * Render event/task details in a table */ function itip_object_details_table($event, $title) { $table = new html_table(array('cols' => 2, 'border' => 0, 'class' => 'calendar-eventdetails')); $table->add('ititle', $title); $table->add('title', Q($event['title'])); - $table->add('label', $this->plugin->gettext('date'), $this->domain); - $table->add('date', Q($this->lib->event_date_text($event))); + if ($event['start'] && $event['end']) { + $table->add('label', $this->plugin->gettext('date'), $this->domain); + $table->add('date', Q($this->lib->event_date_text($event))); + } + else if ($event['due'] && $event['_type'] == 'task') { + $table->add('label', $this->plugin->gettext('date'), $this->domain); + $table->add('date', Q($this->lib->event_date_text($event))); + } if ($event['location']) { $table->add('label', $this->plugin->gettext('location'), $this->domain); $table->add('location', Q($event['location'])); diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 0eef727c..a8b70cb3 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -247,11 +247,25 @@ class libcalendaring extends rcube_plugin */ public function event_date_text($event, $tzinfo = false) { - $fromto = ''; + $fromto = '--'; + + // handle task objects + if ($event['_type'] == 'task' && is_object($event['due'])) { + $date_format = $event['due']->_dateonly ? self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])) : null; + $fromto = $this->rc->format_date($event['due'], $date_format, false); + + // add timezone information + if ($fromto && $tzinfo && ($tzname = $this->timezone->getName())) { + $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; + } + + return $fromto; + } // abort if no valid event dates are given - if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) + if (!is_object($event['start']) || !is_a($event['start'], 'DateTime') || !is_object($event['end']) || !is_a($event['end'], 'DateTime')) { return $fromto; + } $duration = $event['start']->diff($event['end'])->format('s'); diff --git a/plugins/libcalendaring/libvcalendar.php b/plugins/libcalendaring/libvcalendar.php index 855e0749..a89cec2b 100644 --- a/plugins/libcalendaring/libvcalendar.php +++ b/plugins/libcalendaring/libvcalendar.php @@ -567,7 +567,7 @@ class libvcalendar implements Iterator } // make organizer part of the attendees list for compatibility reasons - if (!empty($event['organizer']) && is_array($event['attendees'])) { + if (!empty($event['organizer']) && is_array($event['attendees']) && $event['_type'] == 'event') { array_unshift($event['attendees'], $event['organizer']); } diff --git a/plugins/libcalendaring/localization/en_US.inc b/plugins/libcalendaring/localization/en_US.inc index 26e4ae43..68ddab6b 100644 --- a/plugins/libcalendaring/localization/en_US.inc +++ b/plugins/libcalendaring/localization/en_US.inc @@ -87,6 +87,8 @@ $labels['itipobjectnotfound'] = 'The object referred by this message was not fou $labels['itipsubjectaccepted'] = '"$title" has been accepted by $name'; $labels['itipsubjecttentative'] = '"$title" has been tentatively accepted by $name'; $labels['itipsubjectdeclined'] = '"$title" has been declined by $name'; +$labels['itipsubjectin-process'] = '"$title" is in-process by $name'; +$labels['itipsubjectcompleted'] = '"$title" was completed by $name'; $labels['itipsubjectcancel'] = 'Your participation in "$title" has been cancelled'; $labels['itipnewattendee'] = 'This is a reply from a new participant'; @@ -100,18 +102,24 @@ $labels['youhaveaccepted'] = 'You have accepted this invitation'; $labels['youhavetentative'] = 'You have tentatively accepted this invitation'; $labels['youhavedeclined'] = 'You have declined this invitation'; $labels['youhavedelegated'] = 'You have delegated this invitation'; +$labels['youhavein-process'] = 'You are working on this assignment'; +$labels['youhavecompleted'] = 'You have completed this assignment'; $labels['youhaveneeds-action'] = 'Your response to this invitation is still pending'; $labels['youhavepreviouslyaccepted'] = 'You have previously accepted this invitation'; $labels['youhavepreviouslytentative'] = 'You have previously accepted this invitation tentatively'; $labels['youhavepreviouslydeclined'] = 'You have previously declined this invitation'; $labels['youhavepreviouslydelegated'] = 'You have previously delegated this invitation'; +$labels['youhavepreviouslyin-process'] = 'You have previously reported to work on this assignment'; +$labels['youhavepreviouslycompleted'] = 'You have previously completed this assignment'; $labels['youhavepreviouslyneeds-action'] = 'Your response to this invitation is still pending'; $labels['attendeeaccepted'] = 'Participant has accepted'; $labels['attendeetentative'] = 'Participant has tentatively accepted'; $labels['attendeedeclined'] = 'Participant has declined'; $labels['attendeedelegated'] = 'Participant has delegated to $delegatedto'; +$labels['attendeein-process'] = 'Participant is in-process'; +$labels['attendeecompleted'] = 'Participant has completed'; $labels['notanattendee'] = 'You\'re not listed as an attendee of this object'; $labels['outdatedinvitation'] = 'This invitation has been replaced by a newer version'; diff --git a/plugins/libkolab/lib/kolab_format_event.php b/plugins/libkolab/lib/kolab_format_event.php index 596d0da3..c233f444 100644 --- a/plugins/libkolab/lib/kolab_format_event.php +++ b/plugins/libkolab/lib/kolab_format_event.php @@ -26,7 +26,7 @@ class kolab_format_event extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.event'; - public static $scheduling_properties = array('start', 'end', 'allday', 'location', 'status', 'cancelled'); + public $scheduling_properties = array('start', 'end', 'allday', 'location', 'status', 'cancelled'); protected $objclass = 'Event'; protected $read_func = 'readEvent'; diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index ee0ca6a9..52744d45 100644 --- a/plugins/libkolab/lib/kolab_format_task.php +++ b/plugins/libkolab/lib/kolab_format_task.php @@ -26,7 +26,7 @@ class kolab_format_task extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.task'; - public static $scheduling_properties = array('start', 'due', 'summary', 'status'); + public $scheduling_properties = array('start', 'due', 'summary', 'status'); protected $objclass = 'Todo'; protected $read_func = 'readTodo'; diff --git a/plugins/libkolab/lib/kolab_format_xcal.php b/plugins/libkolab/lib/kolab_format_xcal.php index 6624b025..7d077b7c 100644 --- a/plugins/libkolab/lib/kolab_format_xcal.php +++ b/plugins/libkolab/lib/kolab_format_xcal.php @@ -29,7 +29,8 @@ abstract class kolab_format_xcal extends kolab_format public $CTYPE = 'application/calendar+xml'; public static $fulltext_cols = array('title', 'description', 'location', 'attendees:name', 'attendees:email', 'categories'); - public static $scheduling_properties = array('start', 'end', 'location'); + + public $scheduling_properties = array('start', 'end', 'location'); protected $sensitivity_map = array( 'public' => kolabformat::ClassPublic, @@ -315,7 +316,7 @@ abstract class kolab_format_xcal extends kolab_format // increment sequence when updating properties relevant for scheduling. // RFC 5545: "It is incremented [...] each time the Organizer makes a significant revision to the calendar component." // TODO: make the list of properties considered 'significant' for scheduling configurable - foreach (self::$scheduling_properties as $prop) { + foreach ($this->scheduling_properties as $prop) { $a = $old[$prop]; $b = $object[$prop]; if ($object['allday'] && ($prop == 'start' || $prop == 'end') && $a instanceof DateTime && $b instanceof DateTime) { diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php index 1999a8e5..bcc5c06e 100644 --- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -25,16 +25,17 @@ class tasklist_kolab_driver extends tasklist_driver { // features supported by the backend - public $alarms = false; + public $alarms = false; public $attachments = true; - public $undelete = false; // task undelete action + public $attendees = true; + public $undelete = false; // task undelete action public $alarm_types = array('DISPLAY','AUDIO'); private $rc; private $plugin; private $lists; private $folders = array(); - private $tasks = array(); + private $tasks = array(); /** @@ -772,6 +773,9 @@ class tasklist_kolab_driver extends tasklist_driver 'status' => $record['status'], 'parent_id' => $record['parent_id'], 'recurrence' => $record['recurrence'], + 'attendees' => $record['attendees'], + 'organizer' => $record['organizer'], + 'sequence' => $record['sequence'], ); // convert from DateTime to internal date format @@ -815,8 +819,8 @@ class tasklist_kolab_driver extends tasklist_driver } /** - * Convert the given task record into a data structure that can be passed to kolab_storage backend for saving - * (opposite of self::_to_rcube_event()) + * Convert the given task record into a data structure that can be passed to kolab_storage backend for saving + * (opposite of self::_to_rcube_event()) */ private function _from_rcube_task($task, $old = array()) { @@ -824,14 +828,14 @@ class tasklist_kolab_driver extends tasklist_driver $object['categories'] = (array)$task['tags']; if (!empty($task['date'])) { - $object['due'] = new DateTime($task['date'].' '.$task['time'], $this->plugin->timezone); + $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->plugin->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { - $object['start'] = new DateTime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone); + $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); @@ -898,6 +902,14 @@ class tasklist_kolab_driver extends tasklist_driver unset($object['attachments']); } + // allow sequence increments if I'm the organizer + if ($this->plugin->is_organizer($object)) { + unset($object['sequence']); + } + else if (isset($old['sequence'])) { + $object['sequence'] = $old['sequence']; + } + unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags']); return $object; } diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php index eb7663e5..dd2e415f 100644 --- a/plugins/tasklist/drivers/tasklist_driver.php +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -72,6 +72,7 @@ abstract class tasklist_driver // features supported by the backend public $alarms = false; public $attachments = false; + public $attendees = false; public $undelete = false; // task undelete action public $sortable = false; public $alarm_types = array('DISPLAY'); diff --git a/plugins/tasklist/localization/en_US.inc b/plugins/tasklist/localization/en_US.inc index 9fd0e3eb..a7ec1ea3 100644 --- a/plugins/tasklist/localization/en_US.inc +++ b/plugins/tasklist/localization/en_US.inc @@ -33,6 +33,7 @@ $labels['status-needs-action'] = 'Needs action'; $labels['status-in-process'] = 'In process'; $labels['status-completed'] = 'Completed'; $labels['status-cancelled'] = 'Cancelled'; +$labels['assignedto'] = 'Assigned to'; $labels['all'] = 'All'; $labels['flagged'] = 'Flagged'; @@ -50,6 +51,7 @@ $labels['newtask'] = 'New Task'; $labels['edittask'] = 'Edit Task'; $labels['save'] = 'Save'; $labels['cancel'] = 'Cancel'; +$labels['saveandnotify'] = 'Save and Notify'; $labels['addsubtask'] = 'Add subtask'; $labels['deletetask'] = 'Delete task'; $labels['deletethisonly'] = 'Delete this task only'; @@ -58,6 +60,7 @@ $labels['taskactions'] = 'Task options...'; $labels['tabsummary'] = 'Summary'; $labels['tabrecurrence'] = 'Recurrence'; +$labels['tabassignments'] = 'Assignments'; $labels['tabattachments'] = 'Attachments'; $labels['tabsharing'] = 'Sharing'; @@ -76,7 +79,7 @@ $labels['at'] = 'at'; $labels['this'] = 'this'; $labels['next'] = 'next'; -// mesages +// messages $labels['savingdata'] = 'Saving data...'; $labels['errorsaving'] = 'Failed to save data.'; $labels['notasksfound'] = 'No tasks found for the given criteria'; @@ -86,6 +89,9 @@ $labels['deleteparenttasktconfirm'] = 'Do you really want to delete this task an $labels['deletelistconfirm'] = 'Do you really want to delete this list with all its tasks?'; $labels['deletelistconfirmrecursive'] = 'Do you really want to delete this list with all its sub-lists and tasks?'; $labels['aclnorights'] = 'You do not have administrator rights on this task list.'; +$labels['changetaskconfirm'] = 'Update task'; +$labels['changeconfirmnotifications'] = 'Do you want to notify the attendees about the modification?'; +$labels['partstatupdatenotification'] = 'Do you want to notify the organizer about the status change?'; // (hidden) titles and labels for accessibility annotations $labels['quickaddinput'] = 'New task date and title'; @@ -96,3 +102,68 @@ $labels['arialabellistsearchform'] = 'Tasklists search form'; $labels['arialabeltaskselector'] = 'List mode'; $labels['arialabeltasklisting'] = 'Tasks listing'; +// attendees +$labels['attendee'] = 'Assignee'; +$labels['role'] = 'Role'; +$labels['availability'] = 'Avail.'; +$labels['confirmstate'] = 'Status'; +$labels['addattendee'] = 'Add assignee'; +$labels['roleorganizer'] = 'Organizer'; +$labels['rolerequired'] = 'Required'; +$labels['roleoptional'] = 'Optional'; +$labels['rolechair'] = 'Chair'; +$labels['rolenonparticipant'] = 'Observer'; +$labels['sendinvitations'] = 'Send invitations'; +$labels['sendnotifications'] = 'Notify assignees about modifications'; +$labels['sendcancellation'] = 'Notify assignees about task cancellation'; +$labels['invitationsubject'] = 'You\'ve been assigned to "$title"'; +$labels['invitationmailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nPlease find attached an iCalendar file with all the task details which you can import to your tasks application."; +$labels['itipupdatesubject'] = '"$title" has been updated'; +$labels['itipupdatesubjectempty'] = 'A task that concerns you has been updated'; +$labels['itipupdatemailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nPlease find attached an iCalendar file with the updated task details which you can import to your tasks application."; +$labels['itipcancelsubject'] = '"$title" has been canceled'; +$labels['itipcancelmailbody'] = "*\$title*\n\nDue: \$date\n\nAssignees: \$attendees\n\nThe task has been cancelled by \$organizer.\n\nPlease find attached an iCalendar file with the updated task details."; +$labels['saveintasklist'] = 'save in '; + +// invitation handling (overrides labels from libcalendaring) +$labels['itipobjectnotfound'] = 'The task referred by this message was not found in your tasks list.'; + +$labels['itipmailbodyaccepted'] = "\$sender has accepted the assignment to the following task:\n\n*\$title*\n\nDue: \$date\n\nAssignees: \$attendees"; +$labels['itipmailbodytentative'] = "\$sender has tentatively accepted the assignment to the following task:\n\n*\$title*\n\nDue: \$date\n\nAssignees: \$attendees"; +$labels['itipmailbodydeclined'] = "\$sender has declined the assignment to the following task:\n\n*\$title*\n\nDue: \$date\n\nAssignees: \$attendees"; +$labels['itipmailbodycancel'] = "\$sender has rejected your assignment to the following task:\n\n*\$title*\n\nDue: \$date"; +$labels['itipmailbodyin-process'] = "\$sender has set the status of the following task to in-process:\n\n*\$title*\n\nDue: \$date"; +$labels['itipmailbodycompleted'] = "\$sender has completed the following task:\n\n*\$title*\n\nDue: \$date"; + +$labels['attendeeaccepted'] = 'Assignee has accepted'; +$labels['attendeetentative'] = 'Assignee has tentatively accepted'; +$labels['attendeedeclined'] = 'Assignee has declined'; +$labels['attendeedelegated'] = 'Assignee has delegated to $delegatedto'; +$labels['attendeein-process'] = 'Assignee is in-process'; +$labels['attendeecompleted'] = 'Assignee has completed'; + +$labels['itipdeclinetask'] = 'Decline your assignment to this task to the organizer'; +$labels['declinedeleteconfirm'] = 'Do you also want to delete this declined task from your tasks list?'; +$labels['itipcomment'] = 'Invitation/notification comment'; +$labels['itipcommenttitle'] = 'This comment will be attached to the invitation/notification message sent to assignees'; +$labels['itipsendsuccess'] = 'Invitation sent to assignees'; +$labels['errornotifying'] = 'Failed to send notifications to task assignees'; +$labels['removefromcalendar'] = 'Remove from my tasks'; + +$labels['andnmore'] = '$nr more...'; +$labels['delegatedto'] = 'Delegated to: '; +$labels['delegatedfrom'] = 'Delegated from: '; +$labels['savetotasklist'] = 'Save to tasks'; +$labels['comment'] = 'Comment'; +$labels['errorimportingtask'] = 'Failed to import task(s)'; +$labels['importwarningexists'] = 'A copy of this task already exists in your tasklist.'; +$labels['importsuccess'] = 'Successfully imported $nr tasks'; +$labels['newerversionexists'] = 'A newer version of this task already exists! Aborted.'; +$labels['nowritetasklistfound'] = 'No tasklist found to save the task'; +$labels['importedsuccessfully'] = 'The task was successfully added to \'$list\''; +$labels['updatedsuccessfully'] = 'The task was successfully updated in \'$list\''; +$labels['attendeupdateesuccess'] = 'Successfully updated the participant\'s status'; +$labels['itipresponseerror'] = 'Failed to send the response to this task assignment'; +$labels['itipinvalidrequest'] = 'This invitation is no longer valid'; +$labels['sentresponseto'] = 'Successfully sent assignment response to $mailto'; +$labels['successremoval'] = 'The task has been deleted successfully.'; diff --git a/plugins/tasklist/skins/larry/images/attendee-status.png b/plugins/tasklist/skins/larry/images/attendee-status.png new file mode 100644 index 00000000..5343e601 Binary files /dev/null and b/plugins/tasklist/skins/larry/images/attendee-status.png differ diff --git a/plugins/tasklist/skins/larry/images/badge_cancelled.png b/plugins/tasklist/skins/larry/images/badge_cancelled.png new file mode 100644 index 00000000..b89029e0 Binary files /dev/null and b/plugins/tasklist/skins/larry/images/badge_cancelled.png differ diff --git a/plugins/tasklist/skins/larry/images/loading_blue.gif b/plugins/tasklist/skins/larry/images/loading_blue.gif new file mode 100644 index 00000000..2ea6b19a Binary files /dev/null and b/plugins/tasklist/skins/larry/images/loading_blue.gif differ diff --git a/plugins/tasklist/skins/larry/images/sendinvitation.png b/plugins/tasklist/skins/larry/images/sendinvitation.png new file mode 100644 index 00000000..ecdaa091 Binary files /dev/null and b/plugins/tasklist/skins/larry/images/sendinvitation.png differ diff --git a/plugins/tasklist/skins/larry/images/tasklist.png b/plugins/tasklist/skins/larry/images/tasklist.png new file mode 100644 index 00000000..50ed6300 Binary files /dev/null and b/plugins/tasklist/skins/larry/images/tasklist.png differ diff --git a/plugins/tasklist/skins/larry/tasklist.css b/plugins/tasklist/skins/larry/tasklist.css index e93a3113..96b5e823 100644 --- a/plugins/tasklist/skins/larry/tasklist.css +++ b/plugins/tasklist/skins/larry/tasklist.css @@ -20,7 +20,8 @@ background-position: 0 -26px; } -ul.toolbarmenu li span.icon.taskadd { +ul.toolbarmenu li span.icon.taskadd, +#attachmentmenu li a.tasklistlink span.icon.taskadd { background-image: url(buttons.png); background-position: -4px -90px; } @@ -814,6 +815,10 @@ ul.toolbarmenu li span.delete { color: #999; } +#taskshow.status-cancelled { + background: url(images/badge_cancelled.png) top right no-repeat; +} + #task-parent-title { position: relative; top: -0.6em; @@ -849,6 +854,93 @@ a.morelink:hover { border-bottom: 2px solid #fafafa; } +.edit-attendees-table { + width: 100%; + margin-top: 0.5em; +} + +.edit-attendees-table tbody td { + padding: 4px 7px; +} + +.edit-attendees-table tbody tr:last-child td { + border-bottom: 0; +} + +.edit-attendees-table th.role, +.edit-attendees-table td.role { + width: 9em; +} + +.edit-attendees-table th.availability, +.edit-attendees-table td.availability, +.edit-attendees-table th.confirmstate, +.edit-attendees-table td.confirmstate { + width: 6em; +} + +.edit-attendees-table th.options, +.edit-attendees-table td.options { + width: 24px; + padding: 2px 4px; + text-align: right; +} + +.edit-attendees-table th.sendmail, +.edit-attendees-table td.sendmail { + width: 48px; + padding: 2px; +} + +.edit-attendees-table th.sendmail label { + display: inline-block; + position: relative; + top: 4px; + width: 24px; + height: 18px; + min-width: 24px; + padding: 0; + overflow: hidden; + text-indent: -5000px; + white-space: nowrap; + background: url(images/sendinvitation.png) 1px 0 no-repeat; +} + +.edit-attendees-table th.name, +.edit-attendees-table td.name { + width: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.edit-attendees-table td.name select { + width: 100%; +} + +.edit-attendees-table a.deletelink { + display: inline-block; + width: 17px; + height: 17px; + padding: 0; + overflow: hidden; + text-indent: 1000px; +} + +#edit-attendees-form { + position: relative; + margin-top: 15px; +} + +#edit-attendees-form .attendees-invitebox { + text-align: right; + margin: 0; +} + +#edit-attendees-form .attendees-invitebox label { + padding-right: 3px; +} + #taskedit-attachments { margin: 0.6em 0; } @@ -857,10 +949,11 @@ a.morelink:hover { display: block; color: #333; font-weight: bold; - padding: 8px 4px 3px 30px; + padding: 3px 4px 3px 30px; text-shadow: 0px 1px 1px #fff; text-decoration: none; white-space: nowrap; + line-height: 20px; } #taskedit-attachments ul li a.file { @@ -876,7 +969,7 @@ a.morelink:hover { div.form-section { position: relative; margin-top: 0.2em; - margin-bottom: 0.8em; + margin-bottom: 0.5em; } .form-section label { @@ -891,6 +984,10 @@ label.block { margin-bottom: 0.3em; } +#task-description { + margin-bottom: 1em; +} + #taskedit-completeness-slider { display: inline-block; margin-left: 2em; @@ -935,6 +1032,57 @@ label.block { outline: none; } +.task-attendees span.attendee { + padding-right: 18px; + margin-right: 0.5em; + background: url(images/attendee-status.png) right 0 no-repeat; +} + +.task-attendees span.attendee a.mailtolink { + text-decoration: none; + white-space: nowrap; + outline: none; +} + +.task-attendees span.attendee a.mailtolink:hover { + text-decoration: underline; +} + +.task-attendees span.completed { + background-position: right -20px; +} + +.task-attendees span.declined { + background-position: right -40px; +} + +.task-attendees span.tentative { + background-position: right -60px; +} + +.task-attendees span.delegated { + background-position: right -180px; +} + +.task-attendees span.in-process { + background-position: right -200px; +} + +.task-attendees span.accepted { + background-position: right -220px; +} + +.task-attendees span.organizer { + background-position: right 100px; +} + +#all-task-attendees span.attendee { + display: block; + margin-bottom: 0.4em; + padding-bottom: 0.3em; + border-bottom: 1px solid #ddd; +} + .tasklistview .uidialog .tabbed { min-width: 600px; } @@ -947,6 +1095,128 @@ label.block { width: 20em; } +.task-dialog-message { + margin-top: 0.5em; + padding: 0.8em; + border: 1px solid #ffdf0e; + background-color: #fef893; +} + +.task-dialog-message .message, +.task-update-confirm .message { + margin-bottom: 0.5em; +} + +/* Invitation UI in mail */ + +.messagelist tbody .attachment span.ical { + display: inline-block; + vertical-align: middle; + height: 18px; + width: 20px; + padding: 0; + background: url(images/ical-attachment.png) 2px 1px no-repeat; +} + +div.tasklist-invitebox { + min-height: 20px; + margin: 5px 8px; + padding: 3px 6px 6px 34px; + border: 1px solid #ffdf0e; + background: url(images/tasklist.png) 6px 5px no-repeat #fef893; +} + +div.tasklist-invitebox td.ititle { + font-weight: bold; + padding-right: 0.5em; +} + +div.tasklist-invitebox td.label { + color: #666; + padding-right: 1em; +} + +#event-rsvp .rsvp-buttons, +div.tasklist-invitebox .itip-buttons div { + margin-top: 0.5em; +} + +#event-rsvp input.button, +div.tasklist-invitebox input.button { + font-weight: bold; + margin-right: 0.5em; +} + +div.tasklist-invitebox .folder-select { + font-weight: 10px; + margin-left: 1em; +} + +div.tasklist-invitebox .rsvp-status { + padding-left: 2px; +} + +div.tasklist-invitebox .rsvp-status.loading { + color: #666; + padding: 1px 0 2px 24px; + background: url(images/loading_blue.gif) top left no-repeat; +} + +div.tasklist-invitebox .rsvp-status.hint { + color: #666; + text-shadow: none; + font-style: italic; +} + +#event-partstat .changersvp, +.edit-attendees-table td.confirmstate span, +div.tasklist-invitebox .rsvp-status.declined, +div.tasklist-invitebox .rsvp-status.tentative, +div.tasklist-invitebox .rsvp-status.accepted, +div.tasklist-invitebox .rsvp-status.delegated, +div.tasklist-invitebox .rsvp-status.in-process, +div.tasklist-invitebox .rsvp-status.completed, +div.tasklist-invitebox .rsvp-status.needs-action { + padding: 0 0 1px 22px; + background: url(images/attendee-status.png) 2px -20px no-repeat; +} + +#event-partstat .changersvp.declined, +div.tasklist-invitebox .rsvp-status.declined, +.edit-attendees-table td.confirmstate span.declined { + background-position: 2px -40px; +} + +#event-partstat .changersvp.tentative, +div.tasklist-invitebox .rsvp-status.tentative, +.edit-attendees-table td.confirmstate span.tentative { + background-position: 2px -60px; +} + +#event-partstat .changersvp.delegated, +div.tasklist-invitebox .rsvp-status.delegated, +.edit-attendees-table td.confirmstate span.delegated { + background-position: 2px -180px; +} + +#event-partstat .changersvp.needs-action, +div.tasklist-invitebox .rsvp-status.needs-action, +.edit-attendees-table td.confirmstate span.needs-action { + background-position: 2px 0; +} + +#event-partstat .changersvp.in-process, +div.tasklist-invitebox .rsvp-status.in-process, +.edit-attendees-table td.confirmstate span.in-process { + background-position: 2px -200px; +} + +#event-partstat .changersvp.accepted, +div.tasklist-invitebox .rsvp-status.accepted, +.edit-attendees-table td.confirmstate span.accepted { + background-position: 2px -220px; +} + /** Special hacks for IE7 **/ /** They need to be in this file to also affect the task-create dialog embedded in mail view **/ @@ -954,4 +1224,3 @@ label.block { html.ie7 #taskedit-completeness-slider { display: inline; } - diff --git a/plugins/tasklist/skins/larry/templates/mainview.html b/plugins/tasklist/skins/larry/templates/mainview.html index 3003067a..727d31fc 100644 --- a/plugins/tasklist/skins/larry/templates/mainview.html +++ b/plugins/tasklist/skins/larry/templates/mainview.html @@ -156,6 +156,23 @@ +
+ + +
+
+ + +
+
diff --git a/plugins/tasklist/skins/larry/templates/taskedit.html b/plugins/tasklist/skins/larry/templates/taskedit.html index c4b3c136..25c5796e 100644 --- a/plugins/tasklist/skins/larry/templates/taskedit.html +++ b/plugins/tasklist/skins/larry/templates/taskedit.html @@ -1,7 +1,7 @@
    -
  • +
@@ -79,6 +79,17 @@
+ +
+
+ + +
+

+ + + +
@@ -91,4 +102,5 @@
+
diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js index 0852e126..a0ff22f9 100644 --- a/plugins/tasklist/tasklist.js +++ b/plugins/tasklist/tasklist.js @@ -82,6 +82,8 @@ function rcube_tasklist_ui(settings) var tasklists_widget; var focused_task; var focused_subclass; + var task_attendees = []; + var attendees_list; var me = this; // general datepicker settings @@ -351,9 +353,8 @@ function rcube_tasklist_ui(settings) if (rcmail.busy) return false; - rec.status = e.target.checked ? 'COMPLETED' : (rec.complete == 1 ? 'NEEDS-ACTION' : ''); - li.toggleClass('complete'); - save_task(rec, 'edit'); + save_task_confirm(rec, 'edit', { _status_before:rec.status + '', status:e.target.checked ? 'COMPLETED' : (rec.complete > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION') }); + item.toggleClass('complete'); return true; case 'flagged': @@ -361,7 +362,7 @@ function rcube_tasklist_ui(settings) return false; rec.flagged = rec.flagged ? 0 : 1; - li.toggleClass('flagged').find('.flagged:first').attr('aria-checked', (rec.flagged ? 'true' : 'false')); + item.toggleClass('flagged').find('.flagged:first').attr('aria-checked', (rec.flagged ? 'true' : 'false')); save_task(rec, 'edit'); break; @@ -375,8 +376,7 @@ function rcube_tasklist_ui(settings) input.datepicker($.extend({ onClose: function(dateText, inst) { if (dateText != (rec.date || '')) { - rec.date = dateText; - save_task(rec, 'edit'); + save_task_confirm(rec, 'edit', { date:dateText }); } input.datepicker('destroy').remove(); link.html(dateText || rcmail.gettext('nodate','tasklist')); @@ -541,6 +541,47 @@ function rcube_tasklist_ui(settings) if (sel) $(sel).val(''); return false; }); + + // init attendees autocompletion + var ac_props; + // parallel autocompletion + if (rcmail.env.autocomplete_threads > 0) { + ac_props = { + threads: rcmail.env.autocomplete_threads, + sources: rcmail.env.autocomplete_sources + }; + } + rcmail.init_address_input_events($('#edit-attendee-name'), ac_props); + rcmail.addEventListener('autocomplete_insert', function(e){ + if (e.field.name == 'participant') { + $('#edit-attendee-add').click(); + } +// else if (e.field.name == 'resource' && e.data && e.data.email) { +// add_attendee($.extend(e.data, { role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype:'RESOURCE' })); +// e.field.value = ''; +// } + }); + + $('#edit-attendee-add').click(function(){ + var input = $('#edit-attendee-name'); + rcmail.ksearch_blur(); + if (add_attendees(input.val(), { role:'REQ-PARTICIPANT', status:'NEEDS-ACTION', cutype:'INDIVIDUAL' })) { + input.val(''); + } + }); + + // handle change of "send invitations" checkbox + $('#edit-attendees-invite').change(function() { + $('#edit-attendees-donotify,input.edit-attendee-reply').prop('checked', this.checked); + // hide/show comment field + $('.attendees-commentbox')[this.checked ? 'show' : 'hide'](); + }); + + // delegate change task to "send invitations" checkbox + $('#edit-attendees-donotify').change(function() { + $('#edit-attendees-invite').click(); + return false; + }); } /** @@ -928,6 +969,10 @@ function rcube_tasklist_ui(settings) */ function save_task(rec, action) { + // show confirmation dialog when status of an assigned task has changed + if (rec._status_before !== undefined && is_attendee(rec)) + return save_task_confirm(rec, action); + if (!rcmail.busy) { saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('tasks/task', { action:action, t:rec, filter:filtermask }); @@ -938,6 +983,82 @@ function rcube_tasklist_ui(settings) return false; } + /** + * Display confirm dialog when modifying/deleting a task record + */ + var save_task_confirm = function(rec, action, updates) + { + var data = $.extend({}, rec, updates || {}), + notify = false, partstat = false, html = ''; + + // task has attendees, ask whether to notify them + if (has_attendees(rec) && is_organizer(rec)) { + notify = true; + html = rcmail.gettext('changeconfirmnotifications', 'tasklist'); + } + // ask whether to change my partstat and notify organizer + else if (data._status_before !== undefined && data.status && data._status_before != data.status && is_attendee(rec)) { + partstat = true; + html = rcmail.gettext('partstatupdatenotification', 'tasklist'); + } + + // remove to avoid endless recursion + delete data._status_before; + + // show dialog + if (html) { + var $dialog = $('
').html(html); + + var buttons = []; + buttons.push({ + text: rcmail.gettext('saveandnotify', 'tasklist'), + click: function() { + if (notify) data._notify = 1; + if (partstat) data._reportpartstat = data.status == 'CANCELLED' ? 'DECLINED' : data.status; + save_task(data, action); + $(this).dialog('close'); + } + }); + buttons.push({ + text: rcmail.gettext('save', 'tasklist'), + click: function() { + save_task(data, action); + $(this).dialog('close'); + } + }); + buttons.push({ + text: rcmail.gettext('cancel', 'tasklist'), + click: function() { + $(this).dialog('close'); + if (updates) + render_task(rec, rec.id); // restore previous state + } + }); + + $dialog.dialog({ + modal: true, + width: 460, + closeOnEscapeType: false, + dialogClass: 'warning no-close', + title: rcmail.gettext('changetaskconfirm', 'tasklist'), + buttons: buttons, + open: function() { + setTimeout(function(){ + $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); + }, 5); + }, + close: function(){ + $dialog.dialog('destroy').remove(); + } + }).addClass('task-update-confirm').show(); + + return true; + } + + // do update + return save_task(data, action); + } + /** * Remove saving lock and free the UI for new input */ @@ -1298,6 +1419,143 @@ function rcube_tasklist_ui(settings) scroll_timer = window.setTimeout(function(){ tasklist_drag_scroll(container, dir); }, scroll_speed); } + // check if the task has 'real' attendees, excluding the current user + var has_attendees = function(task) + { + return !!(task.attendees && task.attendees.length && (task.attendees.length > 1 || String(task.attendees[0].email).toLowerCase() != settings.identity.email)); + }; + + // check if the current user is an attendee of this task + var is_attendee = function(task, email, role) + { + var i, attendee, emails = email ? ';' + email.toLowerCase() : settings.identity.emails; + + for (i=0; task.attendees && i < task.attendees.length; i++) { + attendee = task.attendees[i]; + if ((!role || attendee.role == role) && attendee.email && emails.indexOf(';'+attendee.email.toLowerCase()) >= 0) { + return attendee; + } + } + + return false; + }; + + // check if the current user is the organizer + var is_organizer = function(task, email) + { + if (!email) email = task.organizer ? task.organizer.email : null; + if (email) + return settings.identity.emails.indexOf(';'+email) >= 0; + return true; + }; + + // add the given list of participants + var add_attendees = function(names, params) + { + names = explode_quoted_string(names.replace(/,\s*$/, ''), ','); + + // parse name/email pairs + var i, item, email, name, success = false; + for (i=0; i < names.length; i++) { + email = name = ''; + item = $.trim(names[i]); + + if (!item.length) { + continue; + } + // address in brackets without name (do nothing) + else if (item.match(/^<[^@]+@[^>]+>$/)) { + email = item.replace(/[<>]/g, ''); + } + // address without brackets and without name (add brackets) + else if (rcube_check_email(item)) { + email = item; + } + // address with name + else if (item.match(/([^\s<@]+@[^>]+)>*$/)) { + email = RegExp.$1; + name = item.replace(email, '').replace(/^["\s<>]+/, '').replace(/["\s<>]+$/, ''); + } + + if (email) { + add_attendee($.extend({ email:email, name:name }, params)); + success = true; + } + else { + alert(rcmail.gettext('noemailwarning')); + } + } + + return success; + }; + + // add the given attendee to the list + var add_attendee = function(data, readonly) + { + if (!me.selected_task) + return false; + + // check for dupes... + var exists = false; + $.each(task_attendees, function(i, v) { exists |= (v.email == data.email); }); + if (exists) + return false; + + var dispname = Q(data.name || data.email); + if (data.email) + dispname = '' + dispname + ''; + + // delete icon + var icon = rcmail.env.deleteicon ? '' : rcmail.gettext('delete'); + var dellink = '' + icon + ''; + var tooltip = data.status || ''; + + // send invitation checkbox + var invbox = ''; + + if (data['delegated-to']) + tooltip = rcmail.gettext('delegatedto', 'tasklist') + data['delegated-to']; + else if (data['delegated-from']) + tooltip = rcmail.gettext('delegatedfrom', 'tasklist') + data['delegated-from']; + + var html = '' + dispname + '' + + '' + Q(data.status || '') + '' + + (data.cutype != 'RESOURCE' ? '' + (readonly || !invbox ? '' : invbox) + '' : '') + + '' + (readonly ? '' : dellink) + ''; + + var tr = $('') + .addClass(String(data.role).toLowerCase()) + .html(html) + .appendTo(attendees_list); + + tr.find('a.deletelink').click({ id:(data.email || data.name) }, function(e) { remove_attendee(this, e.data.id); return false; }); + tr.find('a.mailtolink').click(task_attendee_click); + tr.find('input.edit-attendee-reply').click(function() { + var enabled = $('#edit-attendees-invite:checked').length || $('input.edit-attendee-reply:checked').length; + $('p.attendees-commentbox')[enabled ? 'show' : 'hide'](); + }); + + task_attendees.push(data); + return true; + }; + + // event handler for clicks on an attendee link + var task_attendee_click = function(e) + { + var mailto = this.href.substr(7); + rcmail.command('compose', mailto); + + return false; + }; + + // remove an attendee from the list + var remove_attendee = function(elem, id) + { + $(elem).closest('tr').remove(); + task_attendees = $.grep(task_attendees, function(data) { return (data.name != id && data.email != id) }); + }; + /** * Show task details in a dialog */ @@ -1308,6 +1566,12 @@ function rcube_tasklist_ui(settings) if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); + // remove status-* classes + $dialog.removeClass(function(i, oldclass) { + var oldies = String(oldclass).split(' '); + return $.grep(oldies, function(cls) { return cls.indexOf('status-') === 0 }).join(' '); + }); + if (!(rec = listdata[id]) || clear_popups({})) return; @@ -1326,6 +1590,7 @@ function rcube_tasklist_ui(settings) $('#task-completeness .task-text').html(((rec.complete || 0) * 100) + '%'); $('#task-status')[(rec.status ? 'show' : 'hide')]().children('.task-text').html(rcmail.gettext('status-'+String(rec.status).toLowerCase(),'tasklist')); $('#task-list .task-text').html(Q(me.tasklists[rec.list] ? me.tasklists[rec.list].name : '')); + $('#task-attendees, #task-organizer').hide(); var itags = get_inherited_tags(rec); var taglist = $('#task-tags')[(rec.tags && rec.tags.length || itags.length ? 'show' : 'hide')]().children('.task-text').empty(); @@ -1347,6 +1612,10 @@ function rcube_tasklist_ui(settings) }); } + if (rec.status) { + $dialog.addClass('status-' + String(rec.status).toLowerCase()); + } + if (rec.recurrence && rec.recurrence_text) { $('#task-recurrence').show().children('.task-text').html(Q(rec.recurrence_text)); } @@ -1363,6 +1632,84 @@ function rcube_tasklist_ui(settings) } } + // list task attendees + if (list.attendees && rec.attendees) { + console.log(rec.attendees) +/* + // sort resources to the end + rec.attendees.sort(function(a,b) { + var j = a.cutype == 'RESOURCE' ? 1 : 0, + k = b.cutype == 'RESOURCE' ? 1 : 0; + return (j - k); + }); +*/ + var j, data, rsvp = false, mystatus = null, line, morelink, html = '', overflow = '', + organizer = is_organizer(rec); + + for (j=0; j < rec.attendees.length; j++) { + data = rec.attendees[j]; + + if (data.email && settings.identity.emails.indexOf(';'+data.email) >= 0) { + mystatus = data.status.toLowerCase(); + if (data.status == 'NEEDS-ACTION' || data.status == 'TENTATIVE' || data.rsvp) + rsvp = mystatus; + } + + line = task_attendee_html(data); + + if (morelink) + overflow += line; + else + html += line; + + // stop listing attendees + if (j == 7 && rec.attendees.length >= 7) { + morelink = $('').html(rcmail.gettext('andnmore', 'tasklist').replace('$nr', rec.attendees.length - j - 1)); + } + } + + if (html) { + $('#task-attendees').show() + .children('.task-text') + .html(html) + .find('a.mailtolink').click(task_attendee_click); + + // display all attendees in a popup when clicking the "more" link + if (morelink) { + $('#task-attendees .task-text').append(morelink); + morelink.click(function(e) { + rcmail.show_popup_dialog( + '
' + html + overflow + '
', + rcmail.gettext('tabattendees', 'tasklist'), + null, + {width: 450, modal: false} + ); + $('#all-task-attendees a.mailtolink').click(task_attendee_click); + return false; + }); + } + } +/* + if (mystatus && !rsvp) { + $('#task-partstat').show().children('.changersvp') + .removeClass('accepted tentative declined delegated needs-action') + .addClass(mystatus) + .children('.task-text') + .html(Q(rcmail.gettext('itip' + mystatus, 'libcalendaring'))); + } + + $('#task-rsvp')[(rsvp && !is_organizer(event) && rec.status != 'CANCELLED' ? 'show' : 'hide')](); + $('#task-rsvp .rsvp-buttons input').prop('disabled', false).filter('input[rel='+mystatus+']').prop('disabled', true); + + $('#task-rsvp a.reply-comment-toggle').show(); + $('#task-rsvp .itip-reply-comment textarea').hide().val(''); +*/ + + if (rec.organizer && !organizer) { + $('#task-organizer').show().children('.task-text').html(task_attendee_html(rec.organizer)); + } + } + // define dialog buttons var buttons = []; if (list.editable && !rec.readonly) { @@ -1391,7 +1738,7 @@ function rcube_tasklist_ui(settings) closeOnEscape: true, title: rcmail.gettext('taskdetails', 'tasklist'), open: function() { - $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); + $dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); }, close: function() { $dialog.dialog('destroy').appendTo(document.body); @@ -1405,6 +1752,24 @@ function rcube_tasklist_ui(settings) me.dialog_resize($dialog.get(0), $dialog.height(), 580); } + // render HTML code for displaying an attendee record + function task_attendee_html(data) + { + var dispname = Q(data.name || data.email), tooltip = ''; + + if (data.email) { + tooltip = data.email; + dispname = '' + dispname + ''; + } + + if (data['delegated-to']) + tooltip = rcmail.gettext('delegatedto', 'tasklist') + data['delegated-to']; + else if (data['delegated-from']) + tooltip = rcmail.gettext('delegatedfrom', 'tasklist') + data['delegated-from']; + + return '' + dispname + ' '; + } + /** * Opens the dialog to edit a task */ @@ -1422,7 +1787,7 @@ function rcube_tasklist_ui(settings) return false; me.selected_task = $.extend({ valarms:[] }, rec); // clone task object - rec = me.selected_task; + rec = me.selected_task; // assign temporary id if (!me.selected_task.id) @@ -1442,6 +1807,12 @@ function rcube_tasklist_ui(settings) completeness_slider.slider('value', complete.val()); var taskstatus = $('#taskedit-status').val(rec.status || ''); var tasklist = $('#taskedit-tasklist').val(rec.list || me.selected_list).prop('disabled', rec.parent_id ? true : false); + var notify = $('#edit-attendees-donotify').get(0); + var invite = $('#edit-attendees-invite').get(0); + var comment = $('#edit-attendees-comment'); + + notify.checked = has_attendees(rec); + invite.checked = true; // tag-edit line var tagline = $(rcmail.gui_objects.edittagline).empty(); @@ -1468,6 +1839,48 @@ function rcube_tasklist_ui(settings) // set recurrence me.set_recurrence_edit(rec); + // init attendees tab + var organizer = !rec.attendees || is_organizer(rec), + allow_invitations = organizer || (rec.owner && rec.owner == 'anonymous') || settings.invite_shared; + + task_attendees = []; + attendees_list = $('#edit-attendees-table > tbody').html(''); + $('#edit-attendees-notify')[(notify.checked && allow_invitations ? 'show' : 'hide')](); + $('#edit-localchanges-warning')[(has_attendees(rec) && !(allow_invitations || (rec.owner && is_organizer(rec, rec.owner))) ? 'show' : 'hide')](); + + // attendees (aka assignees) + if (list.attendees) { + var j, data, reply_selected = 0; + if (rec.attendees) { + for (j=0; j < rec.attendees.length; j++) { + data = rec.attendees[j]; + add_attendee(data, !allow_invitations); + if (allow_invitations && !data.noreply) { + reply_selected++; + } + } + } + + // make sure comment box is visible if at least one attendee has reply enabled + // or global "send invitations" checkbox is checked + if (reply_selected || $('#edit-attendees-invite:checked').length) { + $('p.attendees-commentbox').show(); + } + + // select the correct organizer identity + var identity_id = 0; + $.each(settings.identities, function(i,v) { + if (rec.organizer && v == rec.organizer.email) { + identity_id = i; + return false; + } + }); + + $('#edit-attendees-form')[(allow_invitations?'show':'hide')](); + $('#edit-identities-list').val(identity_id); + $('#taskedit-organizer')[(organizer ? 'show' : 'hide')](); + } + // attachments rcmail.enable_command('remove-attachment', list.editable); me.selected_task.deleted_attachments = []; @@ -1491,23 +1904,27 @@ function rcube_tasklist_ui(settings) // define dialog buttons var buttons = {}; buttons[rcmail.gettext('save', 'tasklist')] = function() { + var data = me.selected_task; + data._status_before = me.selected_task.status + ''; + // copy form field contents into task object to save $.each({ title:title, description:description, date:recdate, time:rectime, startdate:recstartdate, starttime:recstarttime, status:taskstatus, list:tasklist }, function(key,input){ - me.selected_task[key] = input.val(); + data[key] = input.val(); }); - me.selected_task.tags = []; - me.selected_task.attachments = []; - me.selected_task.valarms = me.serialize_alarms('#taskedit-alarms'); - me.selected_task.recurrence = me.serialize_recurrence(rectime.val()); + data.tags = []; + data.attachments = []; + data.attendees = task_attendees; + data.valarms = me.serialize_alarms('#taskedit-alarms'); + data.recurrence = me.serialize_recurrence(rectime.val()); // do some basic input validation - if (!me.selected_task.title || !me.selected_task.title.length) { + if (!data.title || !data.title.length) { title.focus(); return false; } - else if (me.selected_task.startdate && me.selected_task.date) { - var startdate = $.datepicker.parseDate(datepicker_settings.dateFormat, me.selected_task.startdate, datepicker_settings); - var duedate = $.datepicker.parseDate(datepicker_settings.dateFormat, me.selected_task.date, datepicker_settings); + else if (data.startdate && data.date) { + var startdate = $.datepicker.parseDate(datepicker_settings.dateFormat, data.startdate, datepicker_settings); + var duedate = $.datepicker.parseDate(datepicker_settings.dateFormat, data.date, datepicker_settings); if (startdate > duedate) { alert(rcmail.gettext('invalidstartduedates', 'tasklist')); return false; @@ -1515,38 +1932,67 @@ function rcube_tasklist_ui(settings) } // collect tags - $('input[type="hidden"]', rcmail.gui_objects.edittagline).each(function(i,elem){ + $('input[type="hidden"]', rcmail.gui_objects.edittagline).each(function(i,elem) { if (elem.value) - me.selected_task.tags.push(elem.value); + data.tags.push(elem.value); }); // including the "pending" one in the text box var newtag = $('#tagedit-input').val(); if (newtag != '') { - me.selected_task.tags.push(newtag); + data.tags.push(newtag); } // uploaded attachments list for (var i in rcmail.env.attachments) { if (i.match(/^rcmfile(.+)/)) - me.selected_task.attachments.push(RegExp.$1); + data.attachments.push(RegExp.$1); } // task assigned to a new list - if (me.selected_task.list && listdata[id] && me.selected_task.list != listdata[id].list) { - me.selected_task._fromlist = list.id; + if (data.list && listdata[id] && data.list != listdata[id].list) { + data._fromlist = list.id; } - me.selected_task.complete = complete.val() / 100; - if (isNaN(me.selected_task.complete)) - me.selected_task.complete = null; + data.complete = complete.val() / 100; + if (isNaN(data.complete)) + data.complete = null; + else if (data.complete == 1.0 && rec.status === '') + data.status = 'COMPLETED'; - if (!me.selected_task.list && list.id) - me.selected_task.list = list.id; + if (!data.list && list.id) + data.list = list.id; - if (!me.selected_task.tags.length) - me.selected_task.tags = ''; + if (!data.tags.length) + data.tags = ''; - if (save_task(me.selected_task, action)) + if (organizer) { + data._identity = $('#edit-identities-list option:selected').val(); + delete data.organizer; + } + + // per-attendee notification suppression + var need_invitation = false; + if (allow_invitations) { + $.each(data.attendees, function (i, v) { + if (v.role != 'ORGANIZER') { + if ($('input.edit-attendee-reply[value="' + v.email + '"]').prop('checked')) { + need_invitation = true; + delete data.attendees[i]['noreply']; + } + else { + data.attendees[i].noreply = 1; + } + } + }); + } + + // tell server to send notifications + if ((data.attendees.length || (rec.id && rec.attendees.length)) && allow_invitations && (notify.checked || invite.checked || need_invitation)) { + data._notify = 1; + data._comment = comment.val(); + } + + if (save_task(data, action)) $dialog.dialog('close'); }; @@ -1583,7 +2029,6 @@ function rcube_tasklist_ui(settings) me.dialog_resize($dialog.get(0), $dialog.height(), 580); } - /** * Open a task attachment either in a browser window for inline view or download it */ @@ -1691,7 +2136,34 @@ function rcube_tasklist_ui(settings) if (!rec || rec.readonly || rcmail.busy) return false; - var html, buttons = []; + var html, buttons = [], $dialog = $('
'); + + // Subfunction to submit the delete command after confirm + var _delete_task = function(id, mode) { + var rec = listdata[id], + li = $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).hide(), + decline = $dialog.find('input.confirm-attendees-decline:checked').length, + notify = $dialog.find('input.confirm-attendees-notify:checked').length; + + saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); + rcmail.http_post('task', { action:'delete', t:{ id:rec.id, list:rec.list, _decline:decline, _notify:notify }, mode:mode, filter:filtermask }); + + // move childs to parent/root + if (mode != 1 && rec.children !== undefined) { + var parent_node = rec.parent_id ? $('li[rel="'+rec.parent_id+'"] > .childtasks', rcmail.gui_objects.resultlist) : null; + if (!parent_node || !parent_node.length) + parent_node = rcmail.gui_objects.resultlist; + + $.each(rec.children, function(i,cid) { + var child = listdata[cid]; + child.parent_id = rec.parent_id; + resort_task(child, $('li[rel="'+cid+'"]').appendTo(parent_node), true); + }); + } + + li.remove(); + delete listdata[id]; + } if (rec.children && rec.children.length) { html = rcmail.gettext('deleteparenttasktconfirm','tasklist'); @@ -1721,6 +2193,19 @@ function rcube_tasklist_ui(settings) }); } + if (is_attendee(rec)) { + html += '
' + + '
'; + } + else if (has_attendees(rec) && is_organizer(rec)) { + html += '
' + + '
'; + } + buttons.push({ text: rcmail.gettext('cancel', 'tasklist'), click: function() { @@ -1728,11 +2213,11 @@ function rcube_tasklist_ui(settings) } }); - var $dialog = $('
').html(html); + $dialog.html(html); $dialog.dialog({ modal: true, width: 520, - dialogClass: 'warning', + dialogClass: 'warning no-close', title: rcmail.gettext('deletetask', 'tasklist'), buttons: buttons, close: function(){ @@ -1743,34 +2228,6 @@ function rcube_tasklist_ui(settings) return true; } - /** - * Subfunction to submit the delete command after confirm - */ - function _delete_task(id, mode) - { - var rec = listdata[id], - li = $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).hide(); - - saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); - rcmail.http_post('task', { action:'delete', t:{ id:rec.id, list:rec.list }, mode:mode, filter:filtermask }); - - // move childs to parent/root - if (mode != 1 && rec.children !== undefined) { - var parent_node = rec.parent_id ? $('li[rel="'+rec.parent_id+'"] > .childtasks', rcmail.gui_objects.resultlist) : null; - if (!parent_node || !parent_node.length) - parent_node = rcmail.gui_objects.resultlist; - - $.each(rec.children, function(i,cid) { - var child = listdata[cid]; - child.parent_id = rec.parent_id; - resort_task(child, $('li[rel="'+cid+'"]').appendTo(parent_node), true); - }); - } - - li.remove(); - delete listdata[id]; - } - /** * Check if the given task matches the current filtermask and tag selection */ @@ -2060,6 +2517,29 @@ function rcube_tasklist_ui(settings) /**** Utility functions ****/ + // same as str.split(delimiter) but it ignores delimiters within quoted strings + var explode_quoted_string = function(str, delimiter) + { + var result = [], + strlen = str.length, + q, p, i, char, last; + + for (q = p = i = 0; i < strlen; i++) { + char = str.charAt(i); + if (char == '"' && last != '\\') { + q = !q; + } + else if (!q && char == delimiter) { + result.push(str.substring(p, i)); + p = i + 1; + } + last = char; + } + + result.push(str.substr(p)); + return result; + }; + /** * Clear any text selection * (text is probably selected when double-clicking somewhere) @@ -2205,7 +2685,7 @@ jQuery.unqiqueStrings = (function() { var rctasks; window.rcmail && rcmail.addEventListener('init', function(evt) { - rctasks = new rcube_tasklist_ui(rcmail.env.libcal_settings); + rctasks = new rcube_tasklist_ui($.extend(rcmail.env.tasklist_settings, rcmail.env.libcal_settings)); // register button commands rcmail.register_command('newtask', function(){ rctasks.edit_task(null, 'new', {}); }, true); diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index d227f548..728c4a3c 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -55,6 +55,8 @@ class tasklist extends rcube_plugin public $home; // declare public to be used in other classes private $collapsed_tasks = array(); + private $itip; + private $ical; /** @@ -64,7 +66,7 @@ class tasklist extends rcube_plugin { $this->require_plugin('libcalendaring'); - $this->rc = rcube::get_instance(); + $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->register_task('tasks', 'tasklist'); @@ -106,15 +108,19 @@ class tasklist extends rcube_plugin $this->register_action('mail2task', array($this, 'mail_message2task')); $this->register_action('get-attachment', array($this, 'attachment_get')); $this->register_action('upload', array($this, 'attachment_upload')); + $this->register_action('mailimportitip', array($this, 'mail_import_itip')); + $this->register_action('mailimportattach', array($this, 'mail_import_attachment')); + $this->register_action('itip-status', array($this, 'task_itip_status')); + $this->register_action('itip-remove', array($this, 'task_itip_remove')); + $this->register_action('itip-decline-reply', array($this, 'mail_itip_decline_reply')); $this->add_hook('refresh', array($this, 'refresh')); $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', ''))); } else if ($args['task'] == 'mail') { - // TODO: register hooks to catch ical/vtodo email attachments if ($args['action'] == 'show' || $args['action'] == 'preview') { - // $this->add_hook('message_load', array($this, 'mail_message_load')); - // $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); + $this->add_hook('message_load', array($this, 'mail_message_load')); + $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } // add 'Create event' item to message menu @@ -188,7 +194,7 @@ class tasklist extends rcube_plugin { $filter = intval(get_input_value('filter', RCUBE_INPUT_GPC)); $action = get_input_value('action', RCUBE_INPUT_GPC); - $rec = get_input_value('t', RCUBE_INPUT_POST, true); + $rec = get_input_value('t', RCUBE_INPUT_POST, true); $oldrec = $rec; $success = $refresh = false; @@ -318,8 +324,42 @@ class tasklist extends rcube_plugin $this->rc->output->show_message('successfullysaved', 'confirmation'); $this->update_counts($oldrec, $refresh); } - else + else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); + } + + // send out notifications + if ($success && $rec['_notify'] && ($rec['attendees'] || $oldrec['attendees'])) { + // make sure we have the complete record + $task = $action == 'delete' ? $oldrec : $this->driver->get_task($rec); + + // only notify if data really changed (TODO: do diff check on client already) + if (!$oldrec || $action == 'delete' || self::task_diff($task, $oldrec)) { + $sent = $this->notify_attendees($task, $oldrec, $action, $rec['_comment']); + if ($sent > 0) + $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); + else if ($sent < 0) + $this->rc->output->show_message('tasklist.errornotifying', 'error'); + } + } + else if ($success && $rec['_reportpartstat']) { + // get the full record after update + $task = $this->driver->get_task($rec); + + // send iTip REPLY with the updated partstat + if ($task['organizer'] && ($idx = $this->is_attendee($task)) !== false) { + $sender = $task['attendees'][$idx]; + $status = strtolower($sender['status']); + + $itip = $this->load_itip(); + $itip->set_sender_email($sender['email']); + + if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) + $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation'); + else + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } + } // unlock client $this->rc->output->command('plugin.unlock_saving'); @@ -336,6 +376,21 @@ class tasklist extends rcube_plugin } } + /** + * Load iTIP functions + */ + private function load_itip() + { + if (!$this->itip) { + require_once realpath(__DIR__ . '/../libcalendaring/lib/libcalendaring_itip.php'); + $this->itip = new libcalendaring_itip($this, 'tasklist'); + $this->itip->set_rsvp_actions(array('accepted','declined')); + $this->itip->set_rsvp_status(array('accepted','tentative','declined','delegated','in-process','completed')); + } + + return $this->itip; + } + /** * repares new/edited task properties before save */ @@ -481,6 +536,25 @@ class tasklist extends rcube_plugin $rec['attachments'] = $attachments; + // convert invalid data + if (isset($rec['attendees']) && !is_array($rec['attendees'])) + $rec['attendees'] = array(); + + // copy the task status to my attendee partstat + if (!empty($rec['_reportpartstat'])) { + if (($idx = $this->is_attendee($rec)) !== false) { + if (!($rec['_reportpartstat'] == 'NEEDS-ACTION' && $rec['attendees'][$idx]['status'] == 'ACCEPTED')) + $rec['attendees'][$idx]['status'] = $rec['_reportpartstat']; + else + unset($rec['_reportpartstat']); + } + } + + // set organizer from identity selector + if (isset($rec['_identity']) && ($identity = $this->rc->user->get_identity($rec['_identity']))) { + $rec['organizer'] = array('name' => $identity['name'], 'email' => $identity['email']); + } + if (is_numeric($rec['id']) && $rec['id'] < 0) unset($rec['id']); @@ -586,6 +660,107 @@ class tasklist extends rcube_plugin return $clone; } + /** + * Send out an invitation/notification to all task attendees + */ + private function notify_attendees($task, $old, $action = 'edit', $comment = null) + { + if ($action == 'delete' || ($task['status'] == 'CANCELLED' && $old['status'] != $task['status'])) { + $task['cancelled'] = true; + $is_cancelled = true; + } + + $itip = $this->load_itip(); + $emails = $this->lib->get_user_emails(); + + // add comment to the iTip attachment + $task['comment'] = $comment; + + // needed to generate VTODO instead of VEVENT entry + $task['_type'] = 'task'; + + // compose multipart message using PEAR:Mail_Mime + $method = $action == 'delete' ? 'CANCEL' : 'REQUEST'; + $object = $this->to_libcal($task); + $message = $itip->compose_itip_message($object, $method); + + // list existing attendees from the $old task + $old_attendees = array(); + foreach ((array)$old['attendees'] as $attendee) { + $old_attendees[] = $attendee['email']; + } + + // send to every attendee + $sent = 0; $current = array(); + foreach ((array)$task['attendees'] as $attendee) { + $current[] = strtolower($attendee['email']); + + // skip myself for obvious reasons + if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) { + continue; + } + + // skip if notification is disabled for this attendee + if ($attendee['noreply']) { + continue; + } + + // which template to use for mail text + $is_new = !in_array($attendee['email'], $old_attendees); + $bodytext = $is_cancelled ? 'itipcancelmailbody' : ($is_new ? 'invitationmailbody' : 'itipupdatemailbody'); + $subject = $is_cancelled ? 'itipcancelsubject' : ($is_new ? 'invitationsubject' : ($task['title'] ? 'itipupdatesubject' : 'itipupdatesubjectempty')); + + // finally send the message + if ($itip->send_itip_message($object, $method, $attendee, $subject, $bodytext, $message)) + $sent++; + else + $sent = -100; + } + + // send CANCEL message to removed attendees + foreach ((array)$old['attendees'] as $attendee) { + if (!$attendee['email'] || in_array(strtolower($attendee['email']), $current)) { + continue; + } + + $vtodo = $this->to_libcal($old); + $vtodo['cancelled'] = $is_cancelled; + $vtodo['attendees'] = array($attendee); + $vtodo['comment'] = $comment; + + if ($itip->send_itip_message($vtodo, 'CANCEL', $attendee, 'itipcancelsubject', 'itipcancelmailbody')) + $sent++; + else + $sent = -100; + } + + return $sent; + } + + /** + * Compare two task objects and return differing properties + * + * @param array Event A + * @param array Event B + * @return array List of differing task properties + */ + public static function task_diff($a, $b) + { + $diff = array(); + $ignore = array('changed' => 1, 'attachments' => 1); + + foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { + if (!$ignore[$key] && $a[$key] != $b[$key]) + $diff[] = $key; + } + + // only compare number of attachments + if (count($a['attachments']) != count($b['attachments'])) + $diff[] = 'attachments'; + + return $diff; + } + /** * Dispatcher for tasklist actions initiated by the client */ @@ -902,9 +1077,35 @@ class tasklist extends rcube_plugin else if ($start > $weeklimit || ($rec['date'] && $duedate > $weeklimit)) $mask |= self::FILTER_MASK_LATER; + // TODO: add mask for "assigned to me" + return $mask; } + /** + * Determine whether the current user is an attendee of the given task + */ + public function is_attendee($task) + { + $emails = $this->lib->get_user_emails(); + foreach ((array)$task['attendees'] as $i => $attendee) { + if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { + return $i; + } + } + + return false; + } + + /** + * Determine whether the current user is the organizer of the given task + */ + public function is_organizer($task) + { + $emails = $this->lib->get_user_emails(); + return (empty($task['organizer']) || in_array(strtolower($task['organizer']['email']), $emails)); + } + /******* UI functions ********/ @@ -937,11 +1138,17 @@ class tasklist extends rcube_plugin $texts['tasklist.newtask'] = $this->gettext('createfrommail'); + // collect env variables + $env = array( + 'tasklists' => array(), + 'tasklist_settings' => $this->ui->load_settings(), + ); + $this->ui->init_templates(); echo $this->api->output->parse('tasklist.taskedit', false, false); echo html::tag('link', array('rel' => 'stylesheet', 'type' => 'text/css', 'href' => $this->url($this->local_skin_path() . '/tagedit.css'), 'nl' => true)); echo html::tag('script', array('type' => 'text/javascript'), - "rcmail.set_env('tasklists', " . json_encode($this->api->output->env['tasklists']) . ");\n". + "rcmail.set_env(" . json_encode($env) . ");\n". "rcmail.add_label(" . json_encode($texts) . ");\n" ); exit; @@ -1121,6 +1328,539 @@ class tasklist extends rcube_plugin $this->rc->output->send(); } + /** + * Check mail message structure of there are .ics files attached + * + * @todo move to libcalendaring + */ + public function mail_message_load($p) + { + $this->message = $p['object']; + $itip_part = null; + + // check all message parts for .ics files + foreach ((array)$this->message->mime_parts as $part) { + if ($this->is_vcalendar($part)) { + if ($part->ctype_parameters['method']) + $itip_part = $part->mime_id; + else + $this->ics_parts[] = $part->mime_id; + } + } + + // priorize part with method parameter + if ($itip_part) { + $this->ics_parts = array($itip_part); + } + } + + /** + * Add UI element to copy event invitations or updates to the calendar + * + * @todo move to libcalendaring + */ + public function mail_messagebody_html($p) + { + // load iCalendar functions (if necessary) + if (!empty($this->ics_parts)) { + $this->get_ical(); + $this->load_itip(); + } + + // @todo: Calendar plugin does the same, which means the + // attachment body is fetched twice, this is not optimal + $html = ''; + $has_tasks = false; + foreach ($this->ics_parts as $mime_id) { + $part = $this->message->mime_parts[$mime_id]; + $charset = $part->ctype_parameters['charset'] ? $part->ctype_parameters['charset'] : RCMAIL_CHARSET; + $objects = $this->ical->import($this->message->get_part_content($mime_id), $charset); + $title = $this->gettext('title'); + + // successfully parsed events? + if (empty($objects)) { + continue; + } + + // show a box for every task in the file + foreach ($objects as $idx => $task) { + if ($task['_type'] != 'task') { + continue; + } + + $has_tasks = true; + + // get prepared inline UI for this event object + if ($this->ical->method) { + $html .= html::div('tasklist-invitebox', + $this->itip->mail_itip_inline_ui( + $task, + $this->ical->method, + $mime_id . ':' . $idx, + 'tasks', + rcube_utils::anytodatetime($this->message->headers->date) + ) + ); + } + + // limit listing + if ($idx >= 3) { + break; + } + } + } + + // prepend event boxes to message body + if ($html) { + $this->load_ui(); + $this->ui->init(); + + $p['content'] = $html . $p['content']; + + $this->rc->output->add_label('tasklist.savingdata','tasklist.deletetaskconfirm','tasklist.declinedeleteconfirm'); + } + + // add "Save to tasks" button into attachment menu + if ($has_tasks) { + $this->add_button(array( + 'id' => 'attachmentsavetask', + 'name' => 'attachmentsavetask', + 'type' => 'link', + 'wrapper' => 'li', + 'command' => 'attachment-save-task', + 'class' => 'icon tasklistlink', + 'classact' => 'icon tasklistlink active', + 'innerclass' => 'icon taskadd', + 'label' => 'tasklist.savetotasklist', + ), 'attachmentmenu'); + } + + return $p; + } + + /** + * Read the given mime message from IMAP and parse ical data + * + * @todo move to libcalendaring + */ + private function mail_get_itip_task($mbox, $uid, $mime_id) + { + $charset = RCMAIL_CHARSET; + + // establish imap connection + $imap = $this->rc->get_storage(); + $imap->set_mailbox($mbox); + + if ($uid && $mime_id) { + list($mime_id, $index) = explode(':', $mime_id); + + $part = $imap->get_message_part($uid, $mime_id); + $headers = $imap->get_message_headers($uid); + + if ($part->ctype_parameters['charset']) { + $charset = $part->ctype_parameters['charset']; + } + + if ($part) { + $tasks = $this->get_ical()->import($part, $charset); + } + } + + // successfully parsed events? + if (!empty($tasks) && ($task = $tasks[$index])) { + $task = $this->from_ical($task); + + // store the message's sender address for comparisons + $task['_sender'] = preg_match('/([a-z0-9][a-z0-9\-\.\+\_]*@[^&@"\'.][^@&"\']*\\.([^\\x00-\\x40\\x5b-\\x60\\x7b-\\x7f]{2,}|xn--[a-z0-9]{2,}))/', $headers->from, $m) ? $m[1] : ''; + $askt['_sender_utf'] = rcube_idn_to_utf8($task['_sender']); + + return $task; + } + + return null; + } + + /** + * Checks if specified message part is a vcalendar data + * + * @param rcube_message_part Part object + * + * @return boolean True if part is of type vcard + * + * @todo move to libcalendaring + */ + private function is_vcalendar($part) + { + return ( + in_array($part->mimetype, array('text/calendar', 'text/x-vcalendar', 'application/ics')) || + // Apple sends files as application/x-any (!?) + ($part->mimetype == 'application/x-any' && $part->filename && preg_match('/\.ics$/i', $part->filename)) + ); + } + + /** + * Load iCalendar functions + */ + public function get_ical() + { + if (!$this->ical) { + $this->ical = libcalendaring::get_ical(); + } + + return $this->ical; + } + + /** + * Get properties of the tasklist this user has specified as default + */ + public function get_default_tasklist($writeable = false) + { +// $default_id = $this->rc->config->get('tasklist_default_list'); + $lists = $this->driver->get_lists(); +// $list = $calendars[$default_id] ?: null; + + if (!$list || ($writeable && !$list['editable'])) { + foreach ($lists as $l) { + if ($l['default']) { + $list = $l; + break; + } + + if (!$writeable || $l['editable']) { + $first = $l; + } + } + } + + return $list ?: $first; + } + + /** + * Import the full payload from a mail message attachment + */ + public function mail_import_attachment() + { + $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); + $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); + $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); + $charset = RCMAIL_CHARSET; + + // establish imap connection + $imap = $this->rc->get_storage(); + $imap->set_mailbox($mbox); + + if ($uid && $mime_id) { + $part = $imap->get_message_part($uid, $mime_id); + $headers = $imap->get_message_headers($uid); + + if ($part->ctype_parameters['charset']) { + $charset = $part->ctype_parameters['charset']; + } + + if ($part) { + $tasks = $this->get_ical()->import($part, $charset); + } + } + + $success = $existing = 0; + + if (!empty($tasks)) { + // find writeable tasklist to store task + $cal_id = !empty($_REQUEST['_list']) ? rcube_utils::get_input_value('_list', rcube_utils::INPUT_POST) : null; + $lists = $this->driver->get_lists(); + $list = $lists[$cal_id] ?: $this->get_default_tasklist(true); + + foreach ($tasks as $task) { + // save to tasklist + if ($list && $list['editable'] && $task['_type'] == 'task') { + $task = $this->from_ical($task); + $task['list'] = $list['id']; + + if (!$this->driver->get_task($task['uid'])) { + $success += (bool) $this->driver->create_task($task); + } + else { + $existing++; + } + } + } + } + + if ($success) { + $this->rc->output->command('display_message', $this->gettext(array( + 'name' => 'importsuccess', + 'vars' => array('nr' => $success), + )), 'confirmation'); + } + else if ($existing) { + $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning'); + } + else { + $this->rc->output->command('display_message', $this->gettext('errorimportingtask'), 'error'); + } + } + + /** + * Handler for POST request to import an event attached to a mail message + */ + public function mail_import_itip() + { + $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); + $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); + $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); + $status = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST); + $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); + $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)) || $status == 'needs-action'; + + $error_msg = $this->gettext('errorimportingtask'); + $success = false; + + // successfully parsed tasks? + if ($task = $this->mail_get_itip_task($mbox, $uid, $mime_id)) { + // find writeable list to store the task + $list_id = !empty($_REQUEST['_list']) ? rcube_utils::get_input_value('_list', rcube_utils::INPUT_POST) : null; + $lists = $this->driver->get_lists(); + $list = $lists[$list_id] ?: $this->get_default_tasklist(true); + + $metadata = array( + 'uid' => $task['uid'], + 'changed' => is_object($task['changed']) ? $task['changed']->format('U') : 0, + 'sequence' => intval($task['sequence']), + 'fallback' => strtoupper($status), + 'method' => $this->ical->method, + 'task' => 'tasks', + ); + + // update my attendee status according to submitted method + if (!empty($status)) { + $organizer = $task['organizer']; + $emails = $this->lib->get_user_emails(); + + foreach ($task['attendees'] as $i => $attendee) { + if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { + $metadata['attendee'] = $attendee['email']; + $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; + $reply_sender = $attendee['email']; + + $task['attendees'][$i]['status'] = strtoupper($status); + if ($task['attendees'][$i]['status'] != 'NEEDS-ACTION') { + unset($task['attendees'][$i]['rsvp']); // remove RSVP attribute + } + } + } + + // add attendee with this user's default identity if not listed + if (!$reply_sender) { + $sender_identity = $this->rc->user->get_identity(); + $task['attendees'][] = array( + 'name' => $sender_identity['name'], + 'email' => $sender_identity['email'], + 'role' => 'OPT-PARTICIPANT', + 'status' => strtoupper($status), + ); + $metadata['attendee'] = $sender_identity['email']; + } + } + + // save to tasklist + if ($list && $list['editable']) { + $task['list'] = $list['id']; + + // check for existing task with the same UID + $existing = $this->driver->get_task($task['uid']); + + if ($existing) { + // only update attendee status + if ($this->ical->method == 'REPLY') { + // try to identify the attendee using the email sender address + $existing_attendee = -1; + foreach ($existing['attendees'] as $i => $attendee) { + if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { + $existing_attendee = $i; + break; + } + } + + $task_attendee = null; + foreach ($task['attendees'] as $attendee) { + if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { + $task_attendee = $attendee; + $metadata['fallback'] = $attendee['status']; + $metadata['attendee'] = $attendee['email']; + $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; + break; + } + } + + // found matching attendee entry in both existing and new events + if ($existing_attendee >= 0 && $task_attendee) { + $existing['attendees'][$existing_attendee] = $task_attendee; + $success = $this->driver->edit_task($existing); + } + // update the entire attendees block + else if (($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) && $task_attendee) { + $existing['attendees'][] = $task_attendee; + $success = $this->driver->edit_task($existing); + } + else { + $error_msg = $this->gettext('newerversionexists'); + } + } + // delete the task when declined + else if ($status == 'declined' && $delete) { + $deleted = $this->driver->delete_task($existing, true); + $success = true; + } + // import the (newer) task + else if ($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) { + $task['id'] = $existing['id']; + $task['list'] = $existing['list']; + + // preserve my participant status for regular updates + if (empty($status)) { + $emails = $this->lib->get_user_emails(); + foreach ($task['attendees'] as $i => $attendee) { + if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { + foreach ($existing['attendees'] as $j => $_attendee) { + if ($attendee['email'] == $_attendee['email']) { + $task['attendees'][$i] = $existing['attendees'][$j]; + break; + } + } + } + } + } + + // set status=CANCELLED on CANCEL messages + if ($this->ical->method == 'CANCEL') { + $task['status'] = 'CANCELLED'; + } + // show me as free when declined (#1670) + if ($status == 'declined' || $task['status'] == 'CANCELLED') { + $task['free_busy'] = 'free'; + } + + $success = $this->driver->edit_task($task); + } + else if (!empty($status)) { + $existing['attendees'] = $task['attendees']; + if ($status == 'declined') { // show me as free when declined (#1670) + $existing['free_busy'] = 'free'; + } + + $success = $this->driver->edit_event($existing); + } + else { + $error_msg = $this->gettext('newerversionexists'); + } + } + else if (!$existing && ($status != 'declined' || $this->rc->config->get('kolab_invitation_tasklists'))) { + $success = $this->driver->create_task($task); + } + else if ($status == 'declined') { + $error_msg = null; + } + } + else if ($status == 'declined') { + $error_msg = null; + } + else { + $error_msg = $this->gettext('nowritetasklistfound'); + } + } + + if ($success) { + $message = $this->ical->method == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully')); + $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('list' => $list['name']))), 'confirmation'); + + $metadata['rsvp'] = intval($metadata['rsvp']); + $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', 0); + + $this->rc->output->command('plugin.itip_message_processed', $metadata); + $error_msg = null; + } + else if ($error_msg) { + $this->rc->output->command('display_message', $error_msg, 'error'); + } + + // send iTip reply + if ($this->ical->method == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { + $task['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); + $itip = $this->load_itip(); + $itip->set_sender_email($reply_sender); + + if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) + $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ?: $organizer['email']))), 'confirmation'); + else + $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } + + $this->rc->output->send(); + } + + + /**** Task invitation plugin hooks ****/ + + /** + * Handler for calendar/itip-status requests + */ + public function task_itip_status() + { + $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); + + // find local copy of the referenced task + $existing = $this->driver->get_task($data); + $itip = $this->load_itip(); + $response = $itip->get_itip_status($data, $existing); + + // get a list of writeable lists to save new tasks to + if (!$existing && $response['action'] == 'rsvp' || $response['action'] == 'import') { + $lists = $this->driver->get_lists(); + $select = new html_select(array('name' => 'tasklist', 'id' => 'itip-saveto', 'is_escaped' => true)); + $num = 0; + + foreach ($lists as $list) { + if ($list['editable']) { + $select->add($list['name'], $list['id']); + $num++; + } + } + + if ($num <= 1) { + $select = null; + } + } + + if ($select) { + $default_list = $this->get_default_tasklist(true); + $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . ' ' . + $select->show($this->rc->config->get('tasklist_default_list', $default_list['id']))); + } + + $this->rc->output->command('plugin.update_itip_object_status', $response); + } + + /** + * Handler for calendar/itip-remove requests + */ + public function task_itip_remove() + { + $success = false; + $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); + + // search for event if only UID is given + if ($task = $this->driver->get_task($uid)) { + $success = $this->driver->delete_task($task, true); + } + + if ($success) { + $this->rc->output->show_message('tasklist.successremoval', 'confirmation'); + } + else { + $this->rc->output->show_message('tasklist.errorsaving', 'error'); + } + } + /******* Utility functions *******/ @@ -1132,6 +1872,79 @@ class tasklist extends rcube_plugin return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); } + /** + * Map task properties for ical exprort using libcalendaring + */ + public function to_libcal($task) + { + $object = $task; + $object['_type'] = 'task'; + $object['categories'] = (array)$task['tags']; + + // convert to datetime objects + if (!empty($task['date'])) { + $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->timezone); + if (empty($task['time'])) + $object['due']->_dateonly = true; + unset($object['date']); + } + + if (!empty($task['startdate'])) { + $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->timezone); + if (empty($task['starttime'])) + $object['start']->_dateonly = true; + unset($object['startdate']); + } + + $object['complete'] = $task['complete'] * 100; + if ($task['complete'] == 1.0 && empty($task['complete'])) { + $object['status'] = 'COMPLETED'; + } + + if ($task['flagged']) { + $object['priority'] = 1; + } + else if (!$task['priority']) { + $object['priority'] = 0; + } + + return $object; + } + + /** + * Convert task properties from ical parser to the internal format + */ + public function from_ical($vtodo) + { + $task = $vtodo; + + $task['tags'] = array_filter((array)$vtodo['categories']); + $task['flagged'] = $vtodo['priority'] == 1; + $task['complete'] = floatval($vtodo['complete'] / 100); + + // convert from DateTime to internal date format + if (is_a($vtodo['due'], 'DateTime')) { + $due = $this->lib->adjust_timezone($vtodo['due']); + $task['date'] = $due->format('Y-m-d'); + if (!$vtodo['due']->_dateonly) + $task['time'] = $due->format('H:i'); + } + // convert from DateTime to internal date format + if (is_a($vtodo['start'], 'DateTime')) { + $start = $this->lib->adjust_timezone($vtodo['start']); + $task['startdate'] = $start->format('Y-m-d'); + if (!$vtodo['start']->_dateonly) + $task['starttime'] = $start->format('H:i'); + } + if (is_a($vtodo['dtstamp'], 'DateTime')) { + $task['changed'] = $vtodo['dtstamp']; + } + + unset($task['categories'], $task['due'], $task['start'], $task['dtstamp']); + + return $task; + } + /** * Handler for user_delete plugin hook */ @@ -1141,4 +1954,3 @@ class tasklist extends rcube_plugin return $this->driver->user_delete($args); } } - diff --git a/plugins/tasklist/tasklist_base.js b/plugins/tasklist/tasklist_base.js index 6e93b15d..54484182 100644 --- a/plugins/tasklist/tasklist_base.js +++ b/plugins/tasklist/tasklist_base.js @@ -37,6 +37,7 @@ function rcube_tasklist(settings) /* public methods */ this.create_from_mail = create_from_mail; this.mail2taskdialog = mail2task_dialog; + this.save_to_tasklist = save_to_tasklist; /** @@ -56,7 +57,7 @@ function rcube_tasklist(settings) // rcmail.gui_object('attachmentlist', 'attachmentlist'); ui_loaded = true; - me.ui = new rcube_tasklist_ui(settings); + me.ui = new rcube_tasklist_ui($.extend(rcmail.env.tasklist_settings, settings)); create_from_mail(uid); // start over }); return; @@ -80,21 +81,46 @@ function rcube_tasklist(settings) this.ui.edit_task(null, 'new', prop); } + // handler for attachment-save-tasklist commands + function save_to_tasklist() + { + // TODO: show dialog to select the tasklist for importing + if (this.selected_attachment && window.rcube_libcalendaring) { + rcmail.http_post('tasks/mailimportattach', { + _uid: rcmail.env.uid, + _mbox: rcmail.env.mailbox, + _part: this.selected_attachment, + // _list: $('#tasklist-attachment-saveto').val(), + }, rcmail.set_busy(true, 'itip.savingdata')); + } + } + } /* tasklist plugin initialization (for email task) */ window.rcmail && rcmail.env.task == 'mail' && rcmail.addEventListener('init', function(evt) { var tasks = new rcube_tasklist(rcmail.env.libcal_settings); - rcmail.register_command('tasklist-create-from-mail', function() { tasks.create_from_mail() }); - rcmail.addEventListener('plugin.mail2taskdialog', function(p){ tasks.mail2taskdialog(p) }); - rcmail.addEventListener('plugin.unlock_saving', function(p){ tasks.ui && tasks.ui.unlock_saving(); }); + rcmail.register_command('tasklist-create-from-mail', function() { tasks.create_from_mail(); }); + rcmail.register_command('attachment-save-task', function() { tasks.save_to_tasklist(); }); + rcmail.addEventListener('plugin.mail2taskdialog', function(p) { tasks.mail2taskdialog(p); }); + rcmail.addEventListener('plugin.unlock_saving', function(p) { tasks.ui && tasks.ui.unlock_saving(); }); if (rcmail.env.action != 'show') rcmail.env.message_commands.push('tasklist-create-from-mail'); else rcmail.enable_command('tasklist-create-from-mail', true); + rcmail.addEventListener('beforemenu-open', function(p) { + if (p.menu == 'attachmentmenu') { + tasks.selected_attachment = p.id; + var mimetype = rcmail.env.attachments[p.id], + is_ics = mimetype == 'text/calendar' || mimetype == 'text/x-vcalendar' || mimetype == 'application/ics'; + + rcmail.enable_command('attachment-save-task', is_ics); + } + }); + // add contextmenu item if (window.rcm_contextmenu_register_command) { rcm_contextmenu_register_command( @@ -104,4 +130,3 @@ window.rcmail && rcmail.env.task == 'mail' && rcmail.addEventListener('init', fu 'moveto'); } }); - diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index fc7320ef..21b322ec 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -55,10 +55,57 @@ class tasklist_ui $this->plugin->include_script('tasklist_base.js'); // copy config to client - // $this->rc->output->set_env('tasklist_settings', $settings); + $this->rc->output->set_env('tasklist_settings', $this->load_settings()); + + // initialize attendees autocompletion + $this->rc->autocomplete_init(); $this->ready = true; - } + } + + /** + * + */ + function load_settings() + { + $settings = array(); + + $settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0); + + // get user identity to create default attendee + foreach ($this->rc->user->list_identities() as $rec) { + if (!$identity) + $identity = $rec; + + $identity['emails'][] = $rec['email']; + $settings['identities'][$rec['identity_id']] = $rec['email']; + } + + $identity['emails'][] = $this->rc->user->get_username(); + $settings['identity'] = array( + 'name' => $identity['name'], + 'email' => strtolower($identity['email']), + 'emails' => ';' . strtolower(join(';', $identity['emails'])) + ); + + return $settings; + } + + /** + * Render a HTML select box for user identity selection + */ + function identity_select($attrib = array()) + { + $attrib['name'] = 'identity'; + $select = new html_select($attrib); + $identities = $this->rc->user->list_identities(); + + foreach ($identities as $ident) { + $select->add(format_email_recipient($ident['email'], $ident['name']), $ident['identity_id']); + } + + return $select->show(null); + } /** * Register handler methods for the template engine @@ -78,6 +125,10 @@ class tasklist_ui $this->plugin->register_handler('plugin.attachments_form', array($this, 'attachments_form')); $this->plugin->register_handler('plugin.attachments_list', array($this, 'attachments_list')); $this->plugin->register_handler('plugin.filedroparea', array($this, 'file_drop_area')); + $this->plugin->register_handler('plugin.attendees_list', array($this, 'attendees_list')); + $this->plugin->register_handler('plugin.attendees_form', array($this, 'attendees_form')); + $this->plugin->register_handler('plugin.identity_select', array($this, 'identity_select')); + $this->plugin->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); $this->plugin->include_script('jquery.tagedit.js'); $this->plugin->include_script('tasklist.js'); @@ -166,10 +217,11 @@ class tasklist_ui // enrich list properties with settings from the driver if (!$prop['virtual']) { unset($prop['user_id']); - $prop['alarms'] = $this->plugin->driver->alarms; - $prop['undelete'] = $this->plugin->driver->undelete; - $prop['sortable'] = $this->plugin->driver->sortable; + $prop['alarms'] = $this->plugin->driver->alarms; + $prop['undelete'] = $this->plugin->driver->undelete; + $prop['sortable'] = $this->plugin->driver->sortable; $prop['attachments'] = $this->plugin->driver->attachments; + $prop['attendees'] = $this->plugin->driver->attendees; $jsenv[$id] = $prop; } @@ -375,4 +427,53 @@ class tasklist_ui } } + /** + * + */ + function attendees_list($attrib = array()) + { + // add "noreply" checkbox to attendees table only + $invitations = strpos($attrib['id'], 'attend') !== false; + + $invite = new html_checkbox(array('value' => 1, 'id' => 'edit-attendees-invite')); + $table = new html_table(array('cols' => 4 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable')); + +// $table->add_header('role', $this->plugin->gettext('role')); + $table->add_header('name', $this->plugin->gettext($attrib['coltitle'] ?: 'attendee')); + $table->add_header('confirmstate', $this->plugin->gettext('confirmstate')); + if ($invitations) { + $table->add_header(array('class' => 'sendmail', 'title' => $this->plugin->gettext('sendinvitations')), + $invite->show(1) . html::label('edit-attendees-invite', $this->plugin->gettext('sendinvitations'))); + } + $table->add_header('options', ''); + + return $table->show($attrib); + } + + /** + * + */ + function attendees_form($attrib = array()) + { + $input = new html_inputfield(array('name' => 'participant', 'id' => 'edit-attendee-name', 'size' => 30)); + $textarea = new html_textarea(array('name' => 'comment', 'id' => 'edit-attendees-comment', + 'rows' => 4, 'cols' => 55, 'title' => $this->plugin->gettext('itipcommenttitle'))); + + return html::div($attrib, + html::div(null, $input->show() . " " . + html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->plugin->gettext('addattendee'))) + // . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-schedule', 'value' => $this->plugin->gettext('scheduletime').'...')) + ) . + html::p('attendees-commentbox', html::label(null, $this->plugin->gettext('itipcomment') . $textarea->show())) + ); + } + + /** + * + */ + function edit_attendees_notify($attrib = array()) + { + $checkbox = new html_checkbox(array('name' => '_notify', 'id' => 'edit-attendees-donotify', 'value' => 1)); + return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->plugin->gettext('sendnotifications'))); + } }