diff --git a/plugins/calendar/skins/elastic/templates/attachment.html b/plugins/calendar/skins/elastic/templates/attachment.html deleted file mode 100644 index 17fccc54..00000000 --- a/plugins/calendar/skins/elastic/templates/attachment.html +++ /dev/null @@ -1,64 +0,0 @@ - - - -<roundcube:object name="pagetitle" /> - - - - - - -
- -

:

- -

- - -
- - - -
-

-
- -
-
- -
- -
- - - - diff --git a/plugins/calendar/skins/larry/templates/attachment.html b/plugins/calendar/skins/larry/templates/attachment.html deleted file mode 100644 index 17fccc54..00000000 --- a/plugins/calendar/skins/larry/templates/attachment.html +++ /dev/null @@ -1,64 +0,0 @@ - - - -<roundcube:object name="pagetitle" /> - - - - - - -
- -

:

- -

- - -
- - - -
-

-
- -
-
- -
- -
- - - - diff --git a/plugins/kolab_files/lib/kolab_files_engine.php b/plugins/kolab_files/lib/kolab_files_engine.php index 2732c5b9..10e38f3c 100644 --- a/plugins/kolab_files/lib/kolab_files_engine.php +++ b/plugins/kolab_files/lib/kolab_files_engine.php @@ -1,1583 +1,1585 @@ * * Copyright (C) 2013-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_files_engine { private $plugin; private $rc; private $url; private $url_srv; private $timeout = 600; private $files_sort_cols = array('name', 'mtime', 'size'); private $sessions_sort_cols = array('name'); const API_VERSION = 2; /** * Class constructor */ public function __construct($plugin, $client_url, $server_url = null) { $this->url = rtrim(rcube_utils::resolve_url($client_url), '/ '); $this->url_srv = $server_url ? rtrim(rcube_utils::resolve_url($server_url), '/ ') : $this->url; $this->plugin = $plugin; $this->rc = $plugin->rc; $this->timeout = $this->rc->config->get('session_lifetime') * 60; } /** * User interface initialization */ public function ui() { $this->plugin->add_texts('localization/'); $templates = array(); // set templates of Files UI and widgets if ($this->rc->task == 'mail') { if (in_array($this->rc->action, array('', 'show', 'compose'))) { $templates[] = 'compose_plugin'; } if (in_array($this->rc->action, array('show', 'preview', 'get'))) { $templates[] = 'message_plugin'; if ($this->rc->action == 'get') { // add "Save as" button into attachment toolbar $this->plugin->add_button(array( 'id' => 'saveas', 'name' => 'saveas', 'type' => 'link', 'onclick' => 'kolab_directory_selector_dialog()', 'class' => 'button buttonPas saveas', 'classact' => 'button saveas', 'label' => 'kolab_files.save', 'title' => 'kolab_files.saveto', ), 'toolbar'); } else { // add "Save as" button into attachment menu $this->plugin->add_button(array( 'id' => 'attachmenusaveas', 'name' => 'attachmenusaveas', 'type' => 'link', 'wrapper' => 'li', 'onclick' => 'return false', 'class' => 'icon active saveas', 'classact' => 'icon active saveas', 'innerclass' => 'icon active saveas', 'label' => 'kolab_files.saveto', ), 'attachmentmenu'); } } $list_widget = true; } else if (!$this->rc->action && in_array($this->rc->task, array('calendar', 'tasks'))) { $list_widget = true; $templates[] = 'compose_plugin'; } else if ($this->rc->task == 'files') { $templates[] = 'files'; // get list of external sources $this->get_external_storage_drivers(); // these labels may be needed even if fetching ext sources failed $this->plugin->add_label('folderauthtitle', 'authenticating'); } if ($list_widget) { $this->folder_list_env(); $this->plugin->add_label('save', 'cancel', 'saveto', 'saveall', 'fromcloud', 'attachsel', 'selectfiles', 'attaching', 'collection_audio', 'collection_video', 'collection_image', 'collection_document', 'folderauthtitle', 'authenticating' ); } // add taskbar button if (empty($_REQUEST['framed'])) { $this->plugin->add_button(array( 'command' => 'files', 'class' => 'button-files', 'classsel' => 'button-files button-selected', 'innerclass' => 'button-inner', 'label' => 'kolab_files.files', 'type' => 'link' ), 'taskbar'); } if ($_SESSION['kolab_files_caps']['MANTICORE'] || $_SESSION['kolab_files_caps']['WOPI']) { $_SESSION['kolab_files_caps']['DOCEDIT'] = true; $_SESSION['kolab_files_caps']['DOCTYPE'] = $_SESSION['kolab_files_caps']['MANTICORE'] ? 'manticore' : 'wopi'; } $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/style.css'); $this->plugin->include_script($this->url . '/js/files_api.js'); $this->plugin->include_script('kolab_files.js'); $this->rc->output->set_env('files_url', $this->url . '/api/'); $this->rc->output->set_env('files_token', $this->get_api_token()); $this->rc->output->set_env('files_caps', $_SESSION['kolab_files_caps']); $this->rc->output->set_env('files_user', $this->rc->get_user_name()); if ($_SESSION['kolab_files_caps']['DOCEDIT']) { $this->plugin->add_label('declinednotice', 'invitednotice', 'acceptedownernotice', 'declinedownernotice', 'requestednotice', 'acceptednotice', 'declinednotice', 'more', 'accept', 'decline', 'join', 'status', 'when', 'file', 'comment', 'statusaccepted', 'statusinvited', 'statusdeclined', 'statusrequested', 'invitationaccepting', 'invitationdeclining', 'invitationrequesting', 'close', 'invitationtitle', 'sessions'); } if (!empty($templates)) { $collapsed_folders = (string) $this->rc->config->get('kolab_files_collapsed_folders'); $this->rc->output->include_script('treelist.js'); $this->rc->output->set_env('kolab_files_collapsed_folders', $collapsed_folders); // register template objects for dialogs (and main interface) $this->rc->output->add_handlers(array( 'folder-create-form' => array($this, 'folder_create_form'), 'folder-edit-form' => array($this, 'folder_edit_form'), 'folder-mount-form' => array($this, 'folder_mount_form'), 'folder-auth-options'=> array($this, 'folder_auth_options'), 'file-search-form' => array($this, 'file_search_form'), 'file-rename-form' => array($this, 'file_rename_form'), 'file-create-form' => array($this, 'file_create_form'), 'file-edit-dialog' => array($this, 'file_edit_dialog'), 'file-session-dialog' => array($this, 'file_session_dialog'), 'filelist' => array($this, 'file_list'), 'sessionslist' => array($this, 'sessions_list'), 'filequotadisplay' => array($this, 'quota_display'), 'document-editors-dialog' => array($this, 'document_editors_dialog'), )); if ($this->rc->task != 'files') { // add dialog(s) content at the end of page body foreach ($templates as $template) { $this->rc->output->add_footer( $this->rc->output->parse('kolab_files.' . $template, false, false)); } } } } /** * Engine actions handler */ public function actions() { if ($this->rc->task == 'files' && $this->rc->action) { $action = $this->rc->action; } else if ($this->rc->task != 'files' && $_POST['act']) { $action = $_POST['act']; } else { $action = 'index'; } $method = 'action_' . str_replace('-', '_', $action); if (method_exists($this, $method)) { $this->plugin->add_texts('localization/'); $this->{$method}(); } } /** * Template object for folder creation form */ public function folder_create_form($attrib) { $attrib['name'] = 'folder-create-form'; if (empty($attrib['id'])) { $attrib['id'] = 'folder-create-form'; } $input_name = new html_inputfield(array('id' => 'folder-name', 'name' => 'name', 'size' => 30)); $select_parent = new html_select(array('id' => 'folder-parent', 'name' => 'parent')); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $table->add('title', html::label('folder-name', rcube::Q($this->plugin->gettext('foldername')))); $table->add(null, $input_name->show()); $table->add('title', html::label('folder-parent', rcube::Q($this->plugin->gettext('folderinside')))); $table->add(null, $select_parent->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('foldercreating', 'foldercreatenotice', 'create', 'foldercreate', 'cancel'); $this->rc->output->add_gui_object('folder-create-form', $attrib['id']); return $out; } /** * Template object for folder editing form */ public function folder_edit_form($attrib) { $attrib['name'] = 'folder-edit-form'; if (empty($attrib['id'])) { $attrib['id'] = 'folder-edit-form'; } $input_name = new html_inputfield(array('id' => 'folder-edit-name', 'name' => 'name', 'size' => 30)); $select_parent = new html_select(array('id' => 'folder-edit-parent', 'name' => 'parent')); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $table->add('title', html::label('folder-name', rcube::Q($this->plugin->gettext('foldername')))); $table->add(null, $input_name->show()); $table->add('title', html::label('folder-parent', rcube::Q($this->plugin->gettext('folderinside')))); $table->add(null, $select_parent->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('folderupdating', 'folderupdatenotice', 'save', 'folderedit', 'cancel'); $this->rc->output->add_gui_object('folder-edit-form', $attrib['id']); return $out; } /** * Template object for folder mounting form */ public function folder_mount_form($attrib) { $sources = $this->rc->output->get_env('external_sources'); if (empty($sources) || !is_array($sources)) { return ''; } $attrib['name'] = 'folder-mount-form'; if (empty($attrib['id'])) { $attrib['id'] = 'folder-mount-form'; } // build form content $table = new html_table(array('cols' => 2, 'class' => 'propform')); $input_name = new html_inputfield(array('id' => 'folder-mount-name', 'name' => 'name', 'size' => 30)); $input_driver = new html_radiobutton(array('name' => 'driver', 'size' => 30)); $table->add('title', html::label('folder-mount-name', rcube::Q($this->plugin->gettext('name')))); $table->add(null, $input_name->show()); foreach ($sources as $key => $source) { $id = 'source-' . $key; $form = new html_table(array('cols' => 2, 'class' => 'propform driverform')); foreach ((array) $source['form'] as $idx => $label) { $iid = $id . '-' . $idx; $type = stripos($idx, 'pass') !== false ? 'html_passwordfield' : 'html_inputfield'; $input = new $type(array('size' => 30)); $form->add('title', html::label($iid, rcube::Q($label))); $form->add(null, $input->show('', array( 'id' => $iid, 'name' => $key . '[' . $idx . ']' ))); } $row = $input_driver->show(null, array('value' => $key)) . html::img(array('src' => $source['image'], 'alt' => $key, 'title' => $source['name'])) . html::div(null, html::span('name', rcube::Q($source['name'])) . html::br() . html::span('description', rcube::Q($source['description'])) . $form->show() ); $table->add(array('id' => $id, 'colspan' => 2, 'class' => 'source'), $row); } $out = $table->show() . $this->folder_auth_options(array('suffix' => '-form')); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('foldermounting', 'foldermountnotice', 'foldermount', 'save', 'cancel', 'folderauthtitle', 'authenticating' ); $this->rc->output->add_gui_object('folder-mount-form', $attrib['id']); return $out; } /** * Template object for folder authentication options */ public function folder_auth_options($attrib) { $checkbox = new html_checkbox(array( 'name' => 'store_passwords', 'value' => '1', 'id' => 'auth-pass-checkbox' . $attrib['suffix'], )); return html::div('auth-options', $checkbox->show(). ' ' . html::label('auth-pass-checkbox' . $attrib['suffix'], $this->plugin->gettext('storepasswords')) . html::span('description', $this->plugin->gettext('storepasswordsdesc')) ); } /** * Template object for file edit dialog/warnings */ public function file_edit_dialog($attrib) { $this->plugin->add_label('select', 'create', 'cancel', 'editfiledialog', 'editfilesessions', 'newsession', 'ownedsession', 'invitedsession', 'joinsession', 'editfilero', 'editfilerotitle', 'newsessionro' ); return '
'; } /** * Template object for file session dialog */ public function file_session_dialog($attrib) { $this->plugin->add_label('join', 'open', 'close', 'request', 'cancel', 'sessiondialog', 'sessiondialogcontent'); return '
'; } /** * Template object for dcument editors dialog */ public function document_editors_dialog($attrib) { $table = new html_table(array('cols' => 3, 'border' => 0, 'cellpadding' => 0, 'class' => 'records-table')); $table->add_header('username', $this->plugin->gettext('participant')); $table->add_header('status', $this->plugin->gettext('status')); $table->add_header('options', null); $input = new html_inputfield(array('name' => 'participant', 'id' => 'invitation-editor-name', 'size' => 30)); $textarea = new html_textarea(array('name' => 'comment', 'id' => 'invitation-comment', 'rows' => 4, 'cols' => 55, 'title' => $this->plugin->gettext('invitationtexttitle'))); $button = new html_inputfield(array('type' => 'button', 'class' => 'button', 'id' => 'invitation-editor-add', 'value' => $this->plugin->gettext('addparticipant'))); $this->plugin->add_label('manageeditors', 'statusorganizer'); // initialize attendees autocompletion $this->rc->autocomplete_init(); return '
' . $table->show() . html::div(null, html::div(null, $input->show() . " " . $button->show()) . html::p('attendees-commentbox', html::label(null, $this->plugin->gettext('invitationtextlabel') . $textarea->show()) ) ) . '
'; } /** * Template object for file_rename form */ public function file_rename_form($attrib) { $attrib['name'] = 'file-rename-form'; if (empty($attrib['id'])) { $attrib['id'] = 'file-rename-form'; } $input_name = new html_inputfield(array('id' => 'file-rename-name', 'name' => 'name', 'size' => 50)); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $table->add('title', html::label('file-rename-name', rcube::Q($this->plugin->gettext('filename')))); $table->add(null, $input_name->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('save', 'cancel', 'fileupdating', 'renamefile'); $this->rc->output->add_gui_object('file-rename-form', $attrib['id']); return $out; } /** * Template object for file_create form */ public function file_create_form($attrib) { $attrib['name'] = 'file-create-form'; if (empty($attrib['id'])) { $attrib['id'] = 'file-create-form'; } $input_name = new html_inputfield(array('id' => 'file-create-name', 'name' => 'name', 'size' => 30)); $select_parent = new html_select(array('id' => 'file-create-parent', 'name' => 'parent')); $select_type = new html_select(array('id' => 'file-create-type', 'name' => 'type')); $table = new html_table(array('cols' => 2, 'class' => 'propform')); $types = array(); foreach ($this->get_mimetypes('edit') as $type => $mimetype) { $types[$type] = $mimetype['ext']; $select_type->add($mimetype['label'], $type); } $table->add('title', html::label('file-create-name', rcube::Q($this->plugin->gettext('filename')))); $table->add(null, $input_name->show()); $table->add('title', html::label('file-create-type', rcube::Q($this->plugin->gettext('type')))); $table->add(null, $select_type->show()); $table->add('title', html::label('folder-parent', rcube::Q($this->plugin->gettext('folderinside')))); $table->add(null, $select_parent->show()); $out = $table->show(); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag($attrib, $out); } $this->plugin->add_label('create', 'cancel', 'filecreating', 'createfile', 'createandedit', 'copyfile', 'copyandedit'); $this->rc->output->add_gui_object('file-create-form', $attrib['id']); $this->rc->output->set_env('file_extensions', $types); return $out; } /** * Template object for file search form in "From cloud" dialog */ public function file_search_form($attrib) { $attrib['name'] = '_q'; if (empty($attrib['id'])) { $attrib['id'] = 'filesearchbox'; } if ($attrib['type'] == 'search' && !$this->rc->output->browser->khtml) { unset($attrib['type'], $attrib['results']); } $input_q = new html_inputfield($attrib); $out = $input_q->show(); // add some labels to client $this->rc->output->add_label('searching'); $this->rc->output->add_gui_object('filesearchbox', $attrib['id']); // add form tag around text field if (empty($attrib['form'])) { $out = $this->rc->output->form_tag(array( 'action' => '?_task=files', 'name' => "filesearchform", 'onsubmit' => rcmail_output::JS_OBJECT_NAME . ".command('files-search'); return false", ), $out); } return $out; } /** * Template object for files list */ public function file_list($attrib) { return $this->list_handler($attrib, 'files'); } /** * Template object for sessions list */ public function sessions_list($attrib) { return $this->list_handler($attrib, 'sessions'); } /** * Creates unified template object for files|sessions list */ protected function list_handler($attrib, $type = 'files') { $prefix = 'kolab_' . $type . '_'; $c_prefix = 'kolab_files' . ($type != 'files' ? '_' . $type : '') . '_'; // define list of cols to be displayed based on parameter or config if (empty($attrib['columns'])) { $list_cols = $this->rc->config->get($c_prefix . 'list_cols'); $dont_override = $this->rc->config->get('dont_override'); $a_show_cols = is_array($list_cols) ? $list_cols : array('name'); $this->rc->output->set_env($type . '_col_movable', !in_array($c_prefix . 'list_cols', (array)$dont_override)); } else { $columns = str_replace(array("'", '"'), '', $attrib['columns']); $a_show_cols = preg_split('/[\s,;]+/', $columns); } // make sure 'name' and 'options' column is present if (!in_array('name', $a_show_cols)) { array_unshift($a_show_cols, 'name'); } if (!in_array('options', $a_show_cols)) { array_unshift($a_show_cols, 'options'); } $attrib['columns'] = $a_show_cols; // save some variables for use in ajax list $_SESSION[$prefix . 'list_attrib'] = $attrib; // For list in dialog(s) remove all option-like columns if ($this->rc->task != 'files') { $a_show_cols = array_intersect($a_show_cols, $this->{$type . '_sort_cols'}); } // set default sort col/order to session if (!isset($_SESSION[$prefix . 'sort_col'])) $_SESSION[$prefix . 'sort_col'] = $this->rc->config->get($c_prefix . 'sort_col') ?: 'name'; if (!isset($_SESSION[$prefix . 'sort_order'])) $_SESSION[$prefix . 'sort_order'] = strtoupper($this->rc->config->get($c_prefix . 'sort_order') ?: 'asc'); // set client env $this->rc->output->add_gui_object($type . 'list', $attrib['id']); $this->rc->output->set_env($type . '_sort_col', $_SESSION[$prefix . 'sort_col']); $this->rc->output->set_env($type . '_sort_order', $_SESSION[$prefix . 'sort_order']); $this->rc->output->set_env($type . '_coltypes', $a_show_cols); $this->rc->output->include_script('list.js'); // attach css rules for mimetype icons if (!$this->filetypes_style) { $this->plugin->include_stylesheet($this->url . '/skins/default/images/mimetypes/style.css'); $this->filetypes_style = true; } $thead = ''; foreach ($this->list_head($attrib, $a_show_cols, $type) as $cell) { $thead .= html::tag('th', array('class' => $cell['className'], 'id' => $cell['id']), $cell['html']); } return html::tag('table', $attrib, html::tag('thead', null, html::tag('tr', null, $thead)) . html::tag('tbody', null, ''), array('style', 'class', 'id', 'cellpadding', 'cellspacing', 'border', 'summary')); } /** * Creates for message list table */ protected function list_head($attrib, $a_show_cols, $type = 'files') { $prefix = 'kolab_' . $type . '_'; $c_prefix = 'kolab_files_' . ($type != 'files' ? $type : '') . '_'; $skin_path = $_SESSION['skin_path']; // check to see if we have some settings for sorting $sort_col = $_SESSION[$prefix . 'sort_col']; $sort_order = $_SESSION[$prefix . 'sort_order']; $dont_override = (array)$this->rc->config->get('dont_override'); $disabled_sort = in_array($c_prefix . 'sort_col', $dont_override); $disabled_order = in_array($c_prefix . 'sort_order', $dont_override); $this->rc->output->set_env($prefix . 'disabled_sort_col', $disabled_sort); $this->rc->output->set_env($prefix . 'disabled_sort_order', $disabled_order); // define sortable columns if ($disabled_sort) $a_sort_cols = $sort_col && !$disabled_order ? array($sort_col) : array(); else $a_sort_cols = $this->{$type . '_sort_cols'}; if (!empty($attrib['optionsmenuicon'])) { $onclick = 'return ' . rcmail_output::JS_OBJECT_NAME . ".command('menu-open', '{$type}listmenu', this, event)"; $inner = $this->rc->gettext('listoptions'); if (is_string($attrib['optionsmenuicon']) && $attrib['optionsmenuicon'] != 'true') { $inner = html::img(array('src' => $skin_path . $attrib['optionsmenuicon'], 'alt' => $this->rc->gettext('listoptions'))); } $list_menu = html::a(array( 'href' => '#list-options', 'onclick' => $onclick, 'class' => 'listmenu', 'id' => $type . 'listmenulink', 'title' => $this->rc->gettext('listoptions'), 'tabindex' => '0', ), $inner); } else { $list_menu = ''; } $cells = array(); foreach ($a_show_cols as $col) { // get column name switch ($col) { case 'options': $col_name = $list_menu; break; default: $col_name = rcube::Q($this->plugin->gettext($col)); } // make sort links if (in_array($col, $a_sort_cols)) { $col_name = html::a(array( 'href' => "#sort", 'onclick' => 'return ' . rcmail_output::JS_OBJECT_NAME . ".command('$type-sort','$col',this)", 'title' => $this->plugin->gettext('sortby') ), $col_name); } else if ($col_name[0] != '<') { $col_name = '' . $col_name . ''; } $sort_class = $col == $sort_col && !$disabled_order ? " sorted$sort_order" : ''; $class_name = $col.$sort_class; // put it all together $cells[] = array('className' => $class_name, 'id' => "rcm$col", 'html' => $col_name); } return $cells; } /** * Update files|sessions list object */ protected function list_update($prefs, $type = 'files') { $prefix = 'kolab_' . $type . '_list_'; $c_prefix = 'kolab_files' . ($type != 'files' ? '_' . $type : '') . '_list_'; $attrib = $_SESSION[$prefix . 'attrib']; if (!empty($prefs[$c_prefix . 'cols'])) { $attrib['columns'] = $prefs[$c_prefix . 'cols']; $_SESSION[$prefix . 'attrib'] = $attrib; } $a_show_cols = $attrib['columns']; $head = ''; foreach ($this->list_head($attrib, $a_show_cols, $type) as $cell) { $head .= html::tag('th', array('class' => $cell['className'], 'id' => $cell['id']), $cell['html']); } $head = html::tag('tr', null, $head); $this->rc->output->set_env($type . '_coltypes', $a_show_cols); $this->rc->output->command($type . '_list_update', $head); } /** * Template object for file info box */ public function file_info_box($attrib) { // print_r($this->file_data, true); $table = new html_table(array('cols' => 2, 'class' => $attrib['class'])); // file name $table->add('label', $this->plugin->gettext('name').':'); $table->add('data filename', $this->file_data['name']); // file type // @TODO: human-readable type name $table->add('label', $this->plugin->gettext('type').':'); $table->add('data filetype', $this->file_data['type']); // file size $table->add('label', $this->plugin->gettext('size').':'); $table->add('data filesize', $this->rc->show_bytes($this->file_data['size'])); // file modification time $table->add('label', $this->plugin->gettext('mtime').':'); $table->add('data filemtime', $this->file_data['mtime']); // @TODO: for images: width, height, color depth, etc. // @TODO: for text files: count of characters, lines, words return $table->show(); } /** * Template object for file preview frame */ public function file_preview_frame($attrib) { if (empty($attrib['id'])) { $attrib['id'] = 'filepreviewframe'; } if ($frame = $this->file_data['viewer']['frame']) { return $frame; } if ($href = $this->file_data['viewer']['href']) { // file href attribute must be an absolute URL (Bug #2063) if (!empty($href)) { if (!preg_match('|^https?://|', $href)) { $href = $this->url . '/api/' . $href; } } } else { $token = $this->get_api_token(); $href = $this->url . '/api/?method=file_get' . '&file=' . urlencode($this->file_data['filename']) . '&token=' . urlencode($token); } $this->rc->output->add_gui_object('preview_frame', $attrib['id']); $attrib['allowfullscreen'] = true; $attrib['src'] = $href; $attrib['onload'] = 'kolab_files_frame_load(this)'; // editor requires additional arguments via POST if (!empty($this->file_data['viewer']['post'])) { $attrib['src'] = 'program/resources/blank.gif'; $form_content = new html_hiddenfield(); $form_attrib = array( 'action' => $href, 'id' => $attrib['id'] . '-form', 'target' => $attrib['name'], 'method' => 'post', ); foreach ($this->file_data['viewer']['post'] as $name => $value) { $form_content->add(array('name' => $name, 'value' => $value)); } $form = html::tag('form', $form_attrib, $form_content->show()) . html::script(array(), "\$('#{$attrib['id']}-form').submit()"); } return html::iframe($attrib) . $form; } /** * Template object for quota display */ public function quota_display($attrib) { if (!$attrib['id']) { $attrib['id'] = 'rcmquotadisplay'; } $quota_type = !empty($attrib['display']) ? $attrib['display'] : 'text'; $this->rc->output->add_gui_object('quotadisplay', $attrib['id']); $this->rc->output->set_env('quota_type', $quota_type); // get quota $token = $this->get_api_token(); $request = $this->get_request(array('method' => 'quota'), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $quota = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get quota. Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); $quota = array('total' => 0, 'percent' => 0); } $quota = rcube_output::json_serialize($quota); $this->rc->output->add_script(rcmail_output::JS_OBJECT_NAME . ".files_set_quota($quota);", 'docready'); return html::span($attrib, ''); } /** * Get API token for current user session, authenticate if needed */ public function get_api_token($configure = true) { $token = $_SESSION['kolab_files_token']; $time = $_SESSION['kolab_files_time']; if ($token && time() - $this->timeout < $time) { if (time() - $time <= $this->timeout / 2) { return $token; } } $request = $this->get_request(array('method' => 'ping'), $token); try { $url = $request->getUrl(); // Send ping request if ($token) { $url->setQueryVariables(array('method' => 'ping')); $request->setUrl($url); $response = $request->send(); $status = $response->getStatus(); if ($status == 200 && ($body = json_decode($response->getBody(), true))) { if ($body['status'] == 'OK') { $_SESSION['kolab_files_time'] = time(); return $token; } } } // Go with authenticate request $url->setQueryVariables(array('method' => 'authenticate', 'version' => self::API_VERSION)); $request->setUrl($url); $request->setAuth($this->rc->user->get_username(), $this->rc->decrypt($_SESSION['password'])); $response = $request->send(); $status = $response->getStatus(); if ($status == 200 && ($body = json_decode($response->getBody(), true))) { $token = $body['result']['token']; if ($token) { $_SESSION['kolab_files_token'] = $token; $_SESSION['kolab_files_time'] = time(); $_SESSION['kolab_files_caps'] = $body['result']['capabilities']; } } else { throw new Exception(sprintf("Authenticate error (Status: %d)", $status)); } // Configure session if ($configure && $token) { $this->configure($token); } } catch (Exception $e) { rcube::raise_error($e, true, false); } return $token; } /** * Initialize HTTP_Request object */ protected function get_request($get = null, $token = null) { $url = $this->url_srv . '/api/'; if (!$this->request) { $config = array( 'store_body' => true, 'follow_redirects' => true, ); $this->request = libkolab::http_request($url, 'GET', $config); } else { // cleanup try { $this->request->setBody(''); $this->request->setUrl($url); $this->request->setMethod(HTTP_Request2::METHOD_GET); } catch (Exception $e) { rcube::raise_error($e, true, true); } } if ($token) { $this->request->setHeader('X-Session-Token', $token); } if (!empty($get)) { $url = $this->request->getUrl(); $url->setQueryVariables($get); $this->request->setUrl($url); } // some HTTP server configurations require this header $this->request->setHeader('accept', "application/json,text/javascript,*/*"); // Localization $this->request->setHeader('accept-language', $_SESSION['language']); // set Referer which is used as an origin for cross-window // communication with document editor iframe $host = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST']; $this->request->setHeader('referer', $host); return $this->request; } /** * Configure chwala session */ public function configure($token = null, $prefs = array()) { if (!$token) { $token = $this->get_api_token(false); } try { // Configure session $query = array( 'method' => 'configure', 'timezone' => $prefs['timezone'] ?: $this->rc->config->get('timezone'), 'date_format' => $prefs['date_long'] ?: $this->rc->config->get('date_long', 'Y-m-d H:i'), ); $request = $this->get_request($query, $token); $response = $request->send(); $status = $response->getStatus(); if ($status != 200) { throw new Exception(sprintf("Failed to configure chwala session (Status: %d)", $status)); } } catch (Exception $e) { rcube::raise_error($e, true, false); } } /** * Handler for main files interface (Files task) */ protected function action_index() { $this->plugin->add_label( 'uploading', 'attaching', 'uploadsizeerror', 'filedeleting', 'filedeletenotice', 'filedeleteconfirm', 'filemoving', 'filemovenotice', 'filemoveconfirm', 'filecopying', 'filecopynotice', 'fileskip', 'fileskipall', 'fileoverwrite', 'fileoverwriteall' ); $this->folder_list_env(); if ($this->rc->task == 'files') { $this->rc->output->set_env('folder', rcube_utils::get_input_value('folder', rcube_utils::INPUT_GET)); $this->rc->output->set_env('collection', rcube_utils::get_input_value('collection', rcube_utils::INPUT_GET)); } $this->rc->output->add_label('uploadprogress', 'GB', 'MB', 'KB', 'B'); $this->rc->output->set_pagetitle($this->plugin->gettext('files')); $this->rc->output->set_env('file_mimetypes', $this->get_mimetypes()); $this->rc->output->set_env('files_quota', $_SESSION['kolab_files_caps']['QUOTA']); $this->rc->output->set_env('files_max_upload', $_SESSION['kolab_files_caps']['MAX_UPLOAD']); $this->rc->output->set_env('files_progress_name', $_SESSION['kolab_files_caps']['PROGRESS_NAME']); $this->rc->output->set_env('files_progress_time', $_SESSION['kolab_files_caps']['PROGRESS_TIME']); $this->rc->output->send('kolab_files.files'); } /** * Handler for preferences save action */ protected function action_prefs() { $dont_override = (array)$this->rc->config->get('dont_override'); $prefs = array(); $type = rcube_utils::get_input_value('type', rcube_utils::INPUT_POST); $opts = array( 'kolab_files_sort_col' => true, 'kolab_files_sort_order' => true, 'kolab_files_list_cols' => false, ); foreach ($opts as $o => $sess) { if (isset($_POST[$o])) { $value = rcube_utils::get_input_value($o, rcube_utils::INPUT_POST); $session_key = $o; $config_key = $o; if ($type != 'files') { $config_key = str_replace('files', 'files_' . $type, $config_key); } if (in_array($config_key, $dont_override)) { continue; } if ($o == 'kolab_files_list_cols') { $update_list = true; } $prefs[$config_key] = $value; if ($sess) { $_SESSION[$session_key] = $prefs[$config_key]; } } } // save preference values if (!empty($prefs)) { $this->rc->user->save_prefs($prefs); } if (!empty($update_list)) { $this->list_update($prefs, $type); } $this->rc->output->send(); } /** * Handler for file open action */ protected function action_open() { $this->rc->output->set_env('file_mimetypes', $this->get_mimetypes()); $this->file_opener(intval($_GET['_viewer']) & ~4); } /** * Handler for file open action */ protected function action_edit() { $this->plugin->add_label('sessionterminating', 'unsavedchanges', 'documentinviting', 'documentcancelling', 'removeparticipant', 'sessionterminated', 'sessionterminatedtitle'); $this->file_opener(intval($_GET['_viewer'])); } /** * Handler for "save all attachments into cloud" action */ protected function action_save_file() { // $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_POST); $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); $dest = rcube_utils::get_input_value('dest', rcube_utils::INPUT_POST); $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $name = rcube_utils::get_input_value('name', rcube_utils::INPUT_POST); $temp_dir = unslashify($this->rc->config->get('temp_dir')); $message = new rcube_message($uid); $request = $this->get_request(); $url = $request->getUrl(); $files = array(); $errors = array(); $attachments = array(); $request->setMethod(HTTP_Request2::METHOD_POST); $request->setHeader('X-Session-Token', $this->get_api_token()); $url->setQueryVariables(array('method' => 'file_upload', 'folder' => $dest)); $request->setUrl($url); foreach ($message->attachments as $attach_prop) { if (empty($id) || $id == $attach_prop->mime_id) { $filename = strlen($name) ? $name : rcmail_attachment_name($attach_prop, true); $attachments[$filename] = $attach_prop; } } // @TODO: handle error // @TODO: implement file upload using file URI instead of body upload foreach ($attachments as $attach_name => $attach_prop) { $path = tempnam($temp_dir, 'rcmAttmnt'); // save attachment to file if ($fp = fopen($path, 'w+')) { $message->get_part_body($attach_prop->mime_id, false, 0, $fp); } else { $errors[] = true; rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => "Unable to save attachment into file $path"), true, false); continue; } fclose($fp); // send request to the API try { $request->setBody(''); $request->addUpload('file[]', $path, $attach_name, $attach_prop->mimetype); $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $files[] = $attach_name; } else { throw new Exception($body['reason'] ?: "Failed to post file_upload. Status: $status"); } } catch (Exception $e) { unlink($path); $errors[] = $e->getMessage(); rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); continue; } // clean up unlink($path); $request->setBody(''); } if ($count = count($files)) { $msg = $this->plugin->gettext(array('name' => 'saveallnotice', 'vars' => array('n' => $count))); $this->rc->output->show_message($msg, 'confirmation'); } if ($count = count($errors)) { $msg = $this->plugin->gettext(array('name' => 'saveallerror', 'vars' => array('n' => $count))); $this->rc->output->show_message($msg, 'error'); } // @TODO: update quota indicator, make this optional in case files aren't stored in IMAP $this->rc->output->send(); } /** * Handler for "add attachments from the cloud" action */ protected function action_attach_file() { $files = rcube_utils::get_input_value('files', rcube_utils::INPUT_POST); $uploadid = rcube_utils::get_input_value('uploadid', rcube_utils::INPUT_POST); $COMPOSE_ID = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $COMPOSE = null; $errors = array(); $attachments = array(); if ($this->rc->task == 'mail') { if ($COMPOSE_ID && $_SESSION['compose_data_'.$COMPOSE_ID]) { $COMPOSE =& $_SESSION['compose_data_'.$COMPOSE_ID]; } if (!$COMPOSE) { die("Invalid session var!"); } // attachment upload action if (!is_array($COMPOSE['attachments'])) { $COMPOSE['attachments'] = array(); } } // clear all stored output properties (like scripts and env vars) $this->rc->output->reset(); $temp_dir = unslashify($this->rc->config->get('temp_dir')); $request = $this->get_request(); $url = $request->getUrl(); // Use observer object to store HTTP response into a file require_once $this->plugin->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_files_observer.php'; $observer = new kolab_files_observer(); $request->setHeader('X-Session-Token', $this->get_api_token()); // download files from the API and attach them foreach ($files as $file) { // decode filename $file = urldecode($file); // get file information try { $url->setQueryVariables(array('method' => 'file_info', 'file' => $file)); $request->setUrl($url); $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $file_params = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get file_info. Status: $status"); } } catch (Exception $e) { $errors[] = $e->getMessage(); rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); continue; } // set location of downloaded file $path = tempnam($temp_dir, 'rcmAttmnt'); $observer->set_file($path); // download file try { $url->setQueryVariables(array('method' => 'file_get', 'file' => $file)); $request->setUrl($url); $request->attach($observer); $response = $request->send(); $status = $response->getStatus(); $response->getBody(); // returns nothing $request->detach($observer); if ($status != 200 || !file_exists($path)) { throw new Exception("Unable to save file"); } } catch (Exception $e) { $errors[] = $e->getMessage(); rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); continue; } $attachment = array( 'path' => $path, 'size' => $file_params['size'], 'name' => $file_params['name'], 'mimetype' => $file_params['type'], 'group' => $COMPOSE_ID, ); if ($this->rc->task != 'mail') { $attachments[] = $attachment; continue; } $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); if ($attachment['status'] && !$attachment['abort']) { $this->compose_attach_success($attachment, $COMPOSE, $COMPOSE_ID, $uploadid); } else if ($attachment['error']) { $errors[] = $attachment['error']; } else { $errors[] = $this->plugin->gettext('attacherror'); } } if (!empty($errors)) { $this->rc->output->command('display_message', $this->plugin->gettext('attacherror'), 'error'); $this->rc->output->command('remove_from_attachment_list', $uploadid); } else if ($this->rc->task == 'calendar' || $this->rc->task == 'tasks') { // for uploads in events/tasks we'll use its standard upload handler, // for this we have to fake $_FILES and some other POST args foreach ($attachments as $attach) { $_FILES['_attachments']['tmp_name'][] = $attachment['path']; $_FILES['_attachments']['name'][] = $attachment['name']; $_FILES['_attachments']['size'][] = $attachment['size']; $_FILES['_attachments']['type'][] = $attachment['mimetype']; $_FILES['_attachments']['error'][] = null; } $_GET['_uploadid'] = $uploadid; $_GET['_id'] = $COMPOSE_ID; switch ($this->rc->task) { case 'tasks': - libcalendaring::get_instance()->attachment_upload(tasklist::SESSION_KEY); + $handler = new kolab_attachments_handler(); + $handler->attachment_upload(tasklist::SESSION_KEY); break; case 'calendar': - libcalendaring::get_instance()->attachment_upload(calendar::SESSION_KEY, 'cal-'); + $handler = new kolab_attachments_handler(); + $handler->attachment_upload(calendar::SESSION_KEY, 'cal-'); break; } } // send html page with JS calls as response $this->rc->output->command('auto_save_start', false); $this->rc->output->send(); } protected function compose_attach_success($attachment, $COMPOSE, $COMPOSE_ID, $uploadid) { $id = $attachment['id']; // store new attachment in session unset($attachment['data'], $attachment['status'], $attachment['abort']); $this->rc->session->append('compose_data_' . $COMPOSE_ID . '.attachments', $id, $attachment); if (($icon = $COMPOSE['deleteicon']) && is_file($icon)) { $button = html::img(array( 'src' => $icon, 'alt' => $this->rc->gettext('delete') )); } else if ($COMPOSE['textbuttons']) { $button = rcube::Q($this->rc->gettext('delete')); } else { $button = ''; } if (version_compare(version_parse(RCMAIL_VERSION), '1.3.0', '>=')) { $link_content = sprintf('%s (%s)', rcube::Q($attachment['name']), $this->rc->show_bytes($attachment['size'])); $content_link = html::a(array( 'href' => "#load", 'class' => 'filename', 'onclick' => sprintf("return %s.command('load-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), ), $link_content); $delete_link = html::a(array( 'href' => "#delete", 'onclick' => sprintf("return %s.command('remove-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'class' => 'delete', 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], ), $button); $content = $COMPOSE['icon_pos'] == 'left' ? $delete_link.$content_link : $content_link.$delete_link; } else { $content = html::a(array( 'href' => "#delete", 'onclick' => sprintf("return %s.command('remove-attachment','rcmfile%s', this)", rcmail_output::JS_OBJECT_NAME, $id), 'title' => $this->rc->gettext('delete'), 'class' => 'delete', ), $button); $content .= rcube::Q($attachment['name']); } $this->rc->output->command('add2attachment_list', "rcmfile$id", array( 'html' => $content, 'name' => $attachment['name'], 'mimetype' => $attachment['mimetype'], 'classname' => rcube_utils::file2class($attachment['mimetype'], $attachment['name']), 'complete' => true), $uploadid); } /** * Handler for file open/edit action */ protected function file_opener($viewer) { $file = rcube_utils::get_input_value('_file', rcube_utils::INPUT_GET); $session = rcube_utils::get_input_value('_session', rcube_utils::INPUT_GET); // get file info $token = $this->get_api_token(); $request = $this->get_request(array( 'method' => 'file_info', 'file' => $file, 'viewer' => $viewer, 'session' => $session, ), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $this->file_data = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get file_info. Status: $status"); } } catch (Exception $e) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, true); } if ($file === null || $file === '') { $file = $this->file_data['file']; } $this->file_data['filename'] = $file; $this->plugin->add_label('filedeleteconfirm', 'filedeleting', 'filedeletenotice'); // register template objects for dialogs (and main interface) $this->rc->output->add_handlers(array( 'fileinfobox' => array($this, 'file_info_box'), 'filepreviewframe' => array($this, 'file_preview_frame'), )); $placeholder = $this->rc->output->asset_url('program/resources/blank.gif'); if ($this->file_data['viewer']['wopi']) { $editor_type = 'wopi'; $got_editor = ($viewer & 4); } else if ($this->file_data['viewer']['manticore']) { $editor_type = 'manticore'; $got_editor = ($viewer & 4); } // this one is for styling purpose $this->rc->output->set_env('extwin', true); $this->rc->output->set_env('file', $file); $this->rc->output->set_env('file_data', $this->file_data); $this->rc->output->set_env('editor_type', $editor_type); $this->rc->output->set_env('photo_placeholder', $placeholder); $this->rc->output->set_pagetitle(rcube::Q($file)); $this->rc->output->send('kolab_files.' . ($got_editor ? 'docedit' : 'filepreview')); } /** * Returns mimetypes supported by File API viewers */ protected function get_mimetypes($type = 'view') { $mimetypes = array(); // send request to the API try { if ($this->mimetypes === null) { $this->mimetypes = false; $token = $this->get_api_token(); $request = $this->get_request(array('method' => 'mimetypes'), $token); $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $this->mimetypes = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get mimetypes. Status: $status"); } } if (is_array($this->mimetypes)) { if (array_key_exists($type, $this->mimetypes)) { $mimetypes = $this->mimetypes[$type]; } // fallback to static definition if old Chwala is used else if ($type == 'edit') { $mimetypes = array( 'text/plain' => 'txt', 'text/html' => 'html', ); if (!empty($_SESSION['kolab_files_caps']['MANTICORE'])) { $mimetypes = array_merge(array('application/vnd.oasis.opendocument.text' => 'odt'), $mimetypes); } foreach (array_keys($mimetypes) as $type) { list ($app, $label) = explode('/', $type); $label = preg_replace('/[^a-z]/', '', $label); $mimetypes[$type] = array( 'ext' => $mimetypes[$type], 'label' => $this->plugin->gettext('type.' . $label), ); } } else { $mimetypes = $this->mimetypes; } } } catch (Exception $e) { rcube::raise_error(array( 'code' => 500, 'type' => 'php', 'line' => __LINE__, 'file' => __FILE__, 'message' => $e->getMessage()), true, false); } return $mimetypes; } /** * Get list of available external storage drivers */ protected function get_external_storage_drivers() { // first get configured sources from Chwala $token = $this->get_api_token(); $request = $this->get_request(array('method' => 'folder_types'), $token); // send request to the API try { $response = $request->send(); $status = $response->getStatus(); $body = @json_decode($response->getBody(), true); if ($status == 200 && $body['status'] == 'OK') { $sources = $body['result']; } else { throw new Exception($body['reason'] ?: "Failed to get folder_types. Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); return; } $this->rc->output->set_env('external_sources', $sources); } /** * Registers translation labels for folder lists in UI */ protected function folder_list_env() { // folder list and actions $this->plugin->add_label( 'folderdeleting', 'folderdeleteconfirm', 'folderdeletenotice', 'collection_audio', 'collection_video', 'collection_image', 'collection_document', 'additionalfolders', 'listpermanent' ); $this->rc->output->add_label('foldersubscribing', 'foldersubscribed', 'folderunsubscribing', 'folderunsubscribed', 'searching' ); } } diff --git a/plugins/libcalendaring/libcalendaring.js b/plugins/libcalendaring/libcalendaring.js index 0777589b..95e18bc4 100644 --- a/plugins/libcalendaring/libcalendaring.js +++ b/plugins/libcalendaring/libcalendaring.js @@ -1,1468 +1,1453 @@ /** * Basic Javascript utilities for calendar-related plugins * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this page. * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this page. */ function rcube_libcalendaring(settings) { // member vars this.settings = settings || {}; this.alarm_ids = []; this.alarm_dialog = null; this.snooze_popup = null; this.dismiss_link = null; this.group2expand = {}; // abort if env isn't set if (!settings || !settings.date_format) return; // private vars var me = this; var gmt_offset = (new Date().getTimezoneOffset() / -60) - (settings.timezone || 0) - (settings.dst || 0); var client_timezone = new Date().getTimezoneOffset(); // general datepicker settings var datepicker_settings = { // translate from fullcalendar format to datepicker format dateFormat: settings.date_format.replace(/M/g, 'm').replace(/mmmmm/, 'MM').replace(/mmm/, 'M').replace(/dddd/, 'DD').replace(/ddd/, 'D').replace(/yy/g, 'y'), firstDay : settings.first_day, dayNamesMin: settings.days_short, monthNames: settings.months, monthNamesShort: settings.months, changeMonth: false, showOtherMonths: true, selectOtherMonths: true }; /** * Quote html entities */ var Q = this.quote_html = function(str) { return String(str).replace(//g, '>').replace(/"/g, '"'); }; /** * Create a nice human-readable string for the date/time range */ this.event_date_text = function(event, voice) { if (!event.start) return ''; if (!event.end) event.end = event.start; var fromto, duration = event.end.getTime() / 1000 - event.start.getTime() / 1000, until = voice ? ' ' + rcmail.gettext('until','libcalendaring') + ' ' : ' โ€” '; if (event.allDay) { fromto = this.format_datetime(event.start, 1, voice) + (duration > 86400 || event.start.getDay() != event.end.getDay() ? until + this.format_datetime(event.end, 1, voice) : ''); } else if (duration < 86400 && event.start.getDay() == event.end.getDay()) { fromto = this.format_datetime(event.start, 0, voice) + (duration > 0 ? until + this.format_datetime(event.end, 2, voice) : ''); } else { fromto = this.format_datetime(event.start, 0, voice) + (duration > 0 ? until + this.format_datetime(event.end, 0, voice) : ''); } return fromto; }; /** * Checks if the event/task has 'real' attendees, excluding the current user */ this.has_attendees = function(event) { return !!(event.attendees && event.attendees.length && (event.attendees.length > 1 || String(event.attendees[0].email).toLowerCase() != settings.identity.email)); }; /** * Check if the current user is an attendee of this event/task */ this.is_attendee = function(event, role, email) { var i, emails = email ? ';' + email.toLowerCase() : settings.identity.emails; for (i=0; event.attendees && i < event.attendees.length; i++) { if ((!role || event.attendees[i].role == role) && event.attendees[i].email && emails.indexOf(';'+event.attendees[i].email.toLowerCase()) >= 0) { return event.attendees[i]; } } return false; }; /** * Checks if the current user is the organizer of the event/task */ this.is_organizer = function(event, email) { return this.is_attendee(event, 'ORGANIZER', email) || !event.id; }; /** * Check permissions on the given folder object */ this.has_permission = function(folder, perm) { // multiple chars means "either of" if (String(perm).length > 1) { for (var i=0; i < perm.length; i++) { if (this.has_permission(folder, perm[i])) { return true; } } } if (folder.rights && String(folder.rights).indexOf(perm) >= 0) { return true; } return (perm == 'i' && folder.editable) || (perm == 'v' && folder.editable); }; /** * From time and date strings to a real date object */ this.parse_datetime = function(time, date) { // we use the utility function from datepicker to parse dates var date = date ? $.datepicker.parseDate(datepicker_settings.dateFormat, date, datepicker_settings) : new Date(); var time_arr = time.replace(/\s*[ap][.m]*/i, '').replace(/0([0-9])/g, '$1').split(/[:.]/); if (!isNaN(time_arr[0])) { date.setHours(time_arr[0]); if (time.match(/p[.m]*/i) && date.getHours() < 12) date.setHours(parseInt(time_arr[0]) + 12); else if (time.match(/a[.m]*/i) && date.getHours() == 12) date.setHours(0); } if (!isNaN(time_arr[1])) date.setMinutes(time_arr[1]); return date; } /** * Convert an ISO 8601 formatted date string from the server into a Date object. * Timezone information will be ignored, the server already provides dates in user's timezone. */ this.parseISO8601 = function(s) { // already a Date object? if (s && s.getMonth) { return s; } // force d to be on check's YMD, for daylight savings purposes var fixDate = function(d, check) { if (+d) { // prevent infinite looping on invalid dates while (d.getDate() != check.getDate()) { d.setTime(+d + (d < check ? 1 : -1) * 3600000); } } } // derived from http://delete.me.uk/2005/03/iso8601.html var m = s && s.match(/^([0-9]{4})(-([0-9]{2})(-([0-9]{2})([T ]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2})(:?([0-9]{2}))?))?)?)?)?$/); if (!m) { return null; } var date = new Date(m[1], 0, 2), check = new Date(m[1], 0, 2, 9, 0); if (m[3]) { date.setMonth(m[3] - 1); check.setMonth(m[3] - 1); } if (m[5]) { date.setDate(m[5]); check.setDate(m[5]); } fixDate(date, check); if (m[7]) { date.setHours(m[7]); } if (m[8]) { date.setMinutes(m[8]); } if (m[10]) { date.setSeconds(m[10]); } if (m[12]) { date.setMilliseconds(Number("0." + m[12]) * 1000); } fixDate(date, check); return date; } /** * Turn the given date into an ISO 8601 date string understandable by PHPs strtotime() */ this.date2ISO8601 = function(date) { var zeropad = function(num) { return (num < 10 ? '0' : '') + num; }; return date.getFullYear() + '-' + zeropad(date.getMonth()+1) + '-' + zeropad(date.getDate()) + 'T' + zeropad(date.getHours()) + ':' + zeropad(date.getMinutes()) + ':' + zeropad(date.getSeconds()); }; /** * Format the given date object according to user's prefs */ this.format_datetime = function(date, mode, voice) { var res = ''; if (!mode || mode == 1) { res += $.datepicker.formatDate(voice ? 'MM d yy' : datepicker_settings.dateFormat, date, datepicker_settings); } if (!mode) { res += voice ? ' ' + rcmail.gettext('at','libcalendaring') + ' ' : ' '; } if (!mode || mode == 2) { res += this.format_time(date, voice); } return res; } /** * Clone from fullcalendar.js */ this.format_time = function(date, voice) { var zeroPad = function(n) { return (n < 10 ? '0' : '') + n; } var formatters = { s : function(d) { return d.getSeconds() }, ss : function(d) { return zeroPad(d.getSeconds()) }, m : function(d) { return d.getMinutes() }, mm : function(d) { return zeroPad(d.getMinutes()) }, h : function(d) { return d.getHours() % 12 || 12 }, hh : function(d) { return zeroPad(d.getHours() % 12 || 12) }, H : function(d) { return d.getHours() }, HH : function(d) { return zeroPad(d.getHours()) }, t : function(d) { return d.getHours() < 12 ? 'a' : 'p' }, tt : function(d) { return d.getHours() < 12 ? 'am' : 'pm' }, T : function(d) { return d.getHours() < 12 ? 'A' : 'P' }, TT : function(d) { return d.getHours() < 12 ? 'AM' : 'PM' } }; var i, i2, c, formatter, res = '', format = voice ? settings['time_format'].replace(':',' ').replace('HH','H').replace('hh','h').replace('mm','m').replace('ss','s') : settings['time_format']; for (i=0; i < format.length; i++) { c = format.charAt(i); for (i2=Math.min(i+2, format.length); i2 > i; i2--) { if (formatter = formatters[format.substring(i, i2)]) { res += formatter(date); i = i2 - 1; break; } } if (i2 == i) { res += c; } } return res; } /** * Convert the given Date object into a unix timestamp respecting browser's and user's timezone settings */ this.date2unixtime = function(date) { var dst_offset = (client_timezone - date.getTimezoneOffset()) * 60; // adjust DST offset return Math.round(date.getTime()/1000 + gmt_offset * 3600 + dst_offset); } /** * Turn a unix timestamp value into a Date object */ this.fromunixtime = function(ts) { ts -= gmt_offset * 3600; var date = new Date(ts * 1000), dst_offset = (client_timezone - date.getTimezoneOffset()) * 60; if (dst_offset) // adjust DST offset date.setTime((ts + 3600) * 1000); return date; } /** * Simple plaintext to HTML converter, makig URLs clickable */ this.text2html = function(str, maxlen, maxlines) { var html = Q(String(str)); // limit visible text length if (maxlen) { var morelink = '... '+rcmail.gettext('showmore','libcalendaring')+'', lines = html.split(/\r?\n/), words, out = '', len = 0; for (var i=0; i < lines.length; i++) { len += lines[i].length; if (maxlines && i == maxlines - 1) { out += lines[i] + '\n' + morelink; maxlen = html.length * 2; } else if (len > maxlen) { len = out.length; words = lines[i].split(' '); for (var j=0; j < words.length; j++) { len += words[j].length + 1; out += words[j] + ' '; if (len > maxlen) { out += morelink; maxlen = html.length * 2; maxlines = 0; } } out += '\n'; } else out += lines[i] + '\n'; } if (maxlen > str.length) out += ''; html = out; } // simple link parser (similar to rcube_string_replacer class in PHP) var utf_domain = '[^?&@"\'/\\(\\)\\s\\r\\t\\n]+\\.([^\x00-\x2f\x3b-\x40\x5b-\x60\x7b-\x7f]{2,}|xn--[a-z0-9]{2,})'; var url1 = '.:;,', url2 = 'a-z0-9%=#@+?&/_~\\[\\]-'; var link_pattern = new RegExp('([hf]t+ps?://)('+utf_domain+'(['+url1+']?['+url2+']+)*)', 'ig'); var mailto_pattern = new RegExp('([^\\s\\n\\(\\);]+@'+utf_domain+')', 'ig'); var link_replace = function(matches, p1, p2) { var title = '', text = p2; if (p2 && p2.length > 55) { text = p2.substr(0, 45) + '...' + p2.substr(-8); title = p1 + p2; } return ''+p1+text+'' }; return html .replace(link_pattern, link_replace) .replace(mailto_pattern, '$1') .replace(/(mailto:)([^"]+)"/g, '$1$2" onclick="rcmail.command(\'compose\', \'$2\');return false"') .replace(/\n/g, "
"); }; this.init_alarms_edit = function(prefix, index) { var edit_type = $(prefix+' select.edit-alarm-type'), dom_id = edit_type.attr('id'); // register events on alarm fields edit_type.change(function(){ $(this).parent().find('span.edit-alarm-values')[(this.selectedIndex>0?'show':'hide')](); }); $(prefix+' select.edit-alarm-offset').change(function(){ var val = $(this).val(), parent = $(this).parent(); parent.find('.edit-alarm-date, .edit-alarm-time')[val === '@' ? 'show' : 'hide'](); parent.find('.edit-alarm-value').prop('disabled', val === '@' || val === '0'); parent.find('.edit-alarm-related')[val === '@' ? 'hide' : 'show'](); }); $(prefix+' .edit-alarm-date').removeClass('hasDatepicker').removeAttr('id').datepicker(datepicker_settings); this.init_time_autocomplete($(prefix+' .edit-alarm-time')[0], {}); $(prefix).on('click', 'a.delete-alarm', function(e){ if ($(this).closest('.edit-alarm-item').siblings().length > 0) { $(this).closest('.edit-alarm-item').remove(); } return false; }); // set a unique id attribute and set label reference accordingly if ((index || 0) > 0 && dom_id) { dom_id += ':' + (new Date().getTime()); edit_type.attr('id', dom_id); $(prefix+' label:first').attr('for', dom_id); } $(prefix).on('click', 'a.add-alarm', function(e){ var i = $(this).closest('.edit-alarm-item').siblings().length + 1; var item = $(this).closest('.edit-alarm-item').clone(false) .removeClass('first') .appendTo(prefix); me.init_alarms_edit(prefix + ' .edit-alarm-item:eq(' + i + ')', i); $('select.edit-alarm-type, select.edit-alarm-offset', item).change(); return false; }); } this.set_alarms_edit = function(prefix, valarms) { $(prefix + ' .edit-alarm-item:gt(0)').remove(); var i, alarm, domnode, val, offset; for (i=0; i < valarms.length; i++) { alarm = valarms[i]; if (!alarm.action) alarm.action = 'DISPLAY'; domnode = $(prefix + ' .edit-alarm-item').eq(0); if (i > 0) { domnode = domnode.clone(false).removeClass('first').appendTo(prefix); this.init_alarms_edit(prefix + ' .edit-alarm-item:eq(' + i + ')', i); } $('select.edit-alarm-type', domnode).val(alarm.action); $('select.edit-alarm-related', domnode).val(/END/i.test(alarm.related) ? 'end' : 'start'); if (String(alarm.trigger).match(/@(\d+)/)) { var ondate = this.fromunixtime(parseInt(RegExp.$1)); $('select.edit-alarm-offset', domnode).val('@'); $('input.edit-alarm-value', domnode).val(''); $('input.edit-alarm-date', domnode).val(this.format_datetime(ondate, 1)); $('input.edit-alarm-time', domnode).val(this.format_datetime(ondate, 2)); } else if (String(alarm.trigger).match(/^[-+]*0[MHDS]$/)) { $('input.edit-alarm-value', domnode).val('0'); $('select.edit-alarm-offset', domnode).val('0'); } else if (String(alarm.trigger).match(/([-+])(\d+)([MHDS])/)) { val = RegExp.$2; offset = ''+RegExp.$1+RegExp.$3; $('input.edit-alarm-value', domnode).val(val); $('select.edit-alarm-offset', domnode).val(offset); } } // set correct visibility by triggering onchange handlers $(prefix + ' select.edit-alarm-type, ' + prefix + ' select.edit-alarm-offset').change(); }; this.serialize_alarms = function(prefix) { var valarms = []; $(prefix + ' .edit-alarm-item').each(function(i, elem) { var val, offset, alarm = { action: $('select.edit-alarm-type', elem).val(), related: $('select.edit-alarm-related', elem).val() }; if (alarm.action) { offset = $('select.edit-alarm-offset', elem).val(); if (offset == '@') { alarm.trigger = '@' + me.date2unixtime(me.parse_datetime($('input.edit-alarm-time', elem).val(), $('input.edit-alarm-date', elem).val())); } else if (offset === '0') { alarm.trigger = '0S'; } else if (!isNaN((val = parseInt($('input.edit-alarm-value', elem).val()))) && val >= 0) { alarm.trigger = offset[0] + val + offset[1]; } valarms.push(alarm); } }); return valarms; }; // format time string var time_autocomplete_format = function(hour, minutes, start) { var time, diff, unit, duration = '', d = new Date(); d.setHours(hour); d.setMinutes(minutes); time = me.format_time(d); if (start) { diff = Math.floor((d.getTime() - start.getTime()) / 60000); if (diff > 0) { unit = 'm'; if (diff >= 60) { unit = 'h'; diff = Math.round(diff / 3) / 20; } duration = ' (' + diff + unit + ')'; } } return [time, duration]; }; var time_autocomplete_list = function(p, callback) { // Time completions var st, h, step = 15, result = [], now = new Date(), id = String(this.element.attr('id')), m = id.match(/^(.*)-(starttime|endtime)$/), start = (m && m[2] == 'endtime' && (st = $('#' + m[1] + '-starttime').val()) && $('#' + m[1] + '-startdate').val() == $('#' + m[1] + '-enddate').val()) ? me.parse_datetime(st, '') : null, full = p.term - 1 > 0 || p.term.length > 1, hours = start ? start.getHours() : (full ? me.parse_datetime(p.term, '') : now).getHours(), minutes = hours * 60 + (full ? 0 : now.getMinutes()), min = Math.ceil(minutes / step) * step % 60, hour = Math.floor(Math.ceil(minutes / step) * step / 60); // list hours from 0:00 till now for (h = start ? start.getHours() : 0; h < hours; h++) result.push(time_autocomplete_format(h, 0, start)); // list 15min steps for the next two hours for (; h < hour + 2 && h < 24; h++) { while (min < 60) { result.push(time_autocomplete_format(h, min, start)); min += step; } min = 0; } // list the remaining hours till 23:00 while (h < 24) result.push(time_autocomplete_format((h++), 0, start)); return callback(result); }; var time_autocomplete_open = function(event, ui) { // scroll to current time var $this = $(this), widget = $this.autocomplete('widget') menu = $this.data('ui-autocomplete').menu, amregex = /^(.+)(a[.m]*)/i, pmregex = /^(.+)(a[.m]*)/i, val = $(this).val().replace(amregex, '0:$1').replace(pmregex, '1:$1'); widget.css('width', '10em'); if (val === '') menu._scrollIntoView(widget.children('li:first')); else widget.children().each(function() { var li = $(this), html = li.children().first().html() .replace(/\s+\(.+\)$/, '') .replace(amregex, '0:$1') .replace(pmregex, '1:$1'); if (html.indexOf(val) == 0) menu._scrollIntoView(li); }); }; /** * Initializes time autocompletion */ this.init_time_autocomplete = function(elem, props) { var default_props = { delay: 100, minLength: 1, appendTo: props.container || $(elem).parents('form'), source: time_autocomplete_list, open: time_autocomplete_open, // change: time_autocomplete_change, select: function(event, ui) { $(this).val(ui.item[0]).change(); return false; } }; $(elem).attr('autocomplete', "off") .autocomplete($.extend(default_props, props)) .click(function() { // show drop-down upon clicks $(this).autocomplete('search', $(this).val() ? $(this).val().replace(/\D.*/, "") : " "); }); $(elem).data('ui-autocomplete')._renderItem = function(ul, item) { return $('
  • ') .data('ui-autocomplete-item', item) .append('' + item[0] + item[1] + '') .appendTo(ul); }; }; /***** Alarms handling *****/ /** * Display a notification for the given pending alarms */ this.display_alarms = function(alarms) { // clear old alert first if (this.alarm_dialog) this.alarm_dialog.dialog('destroy').remove(); var i, actions, adismiss, asnooze, alarm, html, audio_alarms = [], records = [], event_ids = [], buttons = {}; for (i=0; i < alarms.length; i++) { alarm = alarms[i]; alarm.start = this.parseISO8601(alarm.start); alarm.end = this.parseISO8601(alarm.end); if (alarm.action == 'AUDIO') { audio_alarms.push(alarm); continue; } event_ids.push(alarm.id); html = '

    ' + Q(alarm.title) + '

    '; html += '
    ' + Q(alarm.location || '') + '
    '; html += '
    ' + Q(this.event_date_text(alarm)) + '
    '; adismiss = $('').html(rcmail.gettext('dismiss','libcalendaring')).click(function(e){ me.dismiss_link = $(this); me.dismiss_alarm(me.dismiss_link.data('id'), 0, e); }); asnooze = $('').html(rcmail.gettext('snooze','libcalendaring')).click(function(e){ me.snooze_dropdown($(this), e); e.stopPropagation(); return false; }); actions = $('
    ').addClass('alarm-actions').append(adismiss.data('id', alarm.id)).append(asnooze.data('id', alarm.id)); records.push($('
    ').addClass('alarm-item').html(html).append(actions)); } if (audio_alarms.length) this.audio_alarms(audio_alarms); if (!records.length) return; this.alarm_dialog = $('
    ').attr('id', 'alarm-display').append(records); buttons[rcmail.gettext('close')] = function() { $(this).dialog('close'); }; buttons[rcmail.gettext('dismissall','libcalendaring')] = function(e) { // submit dismissed event_ids to server me.dismiss_alarm(me.alarm_ids.join(','), 0, e); $(this).dialog('close'); }; this.alarm_dialog.appendTo(document.body).dialog({ modal: false, resizable: true, closeOnEscape: false, dialogClass: 'alarms', title: rcmail.gettext('alarmtitle','libcalendaring'), buttons: buttons, open: function() { setTimeout(function() { me.alarm_dialog.parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().focus(); }, 5); }, close: function() { $('#alarm-snooze-dropdown').hide(); $(this).dialog('destroy').remove(); me.alarm_dialog = null; me.alarm_ids = null; }, drag: function(event, ui) { $('#alarm-snooze-dropdown').hide(); } }); this.alarm_dialog.closest('div[role=dialog]').attr('role', 'alertdialog'); this.alarm_ids = event_ids; }; /** * Display a notification and play a sound for a set of alarms */ this.audio_alarms = function(alarms) { var elem, txt = [], src = rcmail.assets_path('plugins/libcalendaring/alarm'), plugin = navigator.mimeTypes ? navigator.mimeTypes['audio/mp3'] : {}; // first generate and display notification text $.each(alarms, function() { txt.push(this.title); }); rcmail.display_message(rcmail.gettext('alarmtitle','libcalendaring') + ': ' + Q(txt.join(', ')), 'notice', 10000); // Internet Explorer does not support wav files, // support in other browsers depends on enabled plugins, // so we use wav as a fallback src += bw.ie || (plugin && plugin.enabledPlugin) ? '.mp3' : '.wav'; // HTML5 try { elem = $('
  • ') .attr('data-value', this.date2ISO8601(date)) .html('' + Q(this.format_datetime(date, 1)) + '') .appendTo('#edit-recurrence-rdates'); $('').attr('href', '#del') .addClass('iconbutton delete') .html(rcmail.get_label('delete', 'libcalendaring')) .attr('title', rcmail.get_label('delete', 'libcalendaring')) .appendTo(li); }; // re-sort the list items by their 'data-value' attribute this.sort_rdates = function() { var mylist = $('#edit-recurrence-rdates'), listitems = mylist.children('li').get(); listitems.sort(function(a, b) { var compA = $(a).attr('data-value'); var compB = $(b).attr('data-value'); return (compA < compB) ? -1 : (compA > compB) ? 1 : 0; }) $.each(listitems, function(idx, item) { mylist.append(item); }); }; /***** Attendee form handling *****/ // expand the given contact group into individual event/task attendees this.expand_attendee_group = function(e, add, remove) { var id = (e.data ? e.data.email : null) || $(e.target).attr('data-email'), role_select = $(e.target).closest('tr').find('select.edit-attendee-role option:selected'); this.group2expand[id] = { link: e.target, data: $.extend({}, e.data || {}), adder: add, remover: remove } // copy group role from the according form element if (role_select.length) { this.group2expand[id].data.role = role_select.val(); } // register callback handler if (!this._expand_attendee_listener) { this._expand_attendee_listener = this.expand_attendee_callback; rcmail.addEventListener('plugin.expand_attendee_callback', function(result) { me._expand_attendee_listener(result); }); } rcmail.http_post('libcal/plugin.expand_attendee_group', { id: id, data: e.data || {} }, rcmail.set_busy(true, 'loading')); }; // callback from server to expand an attendee group this.expand_attendee_callback = function(result) { var attendee, id = result.id, data = this.group2expand[id], row = $(data.link).closest('tr'); // replace group entry with all members returned by the server if (data && data.adder && result.members && result.members.length) { for (var i=0; i < result.members.length; i++) { attendee = result.members[i]; attendee.role = data.data.role; attendee.cutype = 'INDIVIDUAL'; attendee.status = 'NEEDS-ACTION'; data.adder(attendee, null, row); } if (data.remover) { data.remover(data.link, id) } else { row.remove(); } delete this.group2expand[id]; } else { rcmail.display_message(result.error || rcmail.gettext('expandattendeegroupnodata','libcalendaring'), 'error'); } }; // Render message reference links to the given container this.render_message_links = function(links, container, edit, plugin) { var ul = $('
      ').addClass('attachmentslist'); $.each(links, function(i, link) { if (!link.mailurl) return true; // continue var li = $('
    • ').addClass('link') .addClass('message eml') .append($('') .attr('href', link.mailurl) .addClass('messagelink') .text(link.subject || link.uri) ) .appendTo(ul); // add icon to remove the link if (edit) { $('') .attr('href', '#delete') .attr('title', rcmail.gettext('removelink', plugin)) .attr('data-uri', link.uri) .addClass('delete') .text(rcmail.gettext('delete')) .appendTo(li); } }); container.empty().append(ul); } // resize and reposition (center) the dialog window this.dialog_resize = function(id, height, width) { var win = $(window), w = win.width(), h = win.height(); $(id).dialog('option', { height: Math.min(h-20, height+130), width: Math.min(w-20, width+50) }); }; } ////// static methods // render HTML code for displaying an attendee record rcube_libcalendaring.attendee_html = function(data) { var name, tooltip = '', context = 'libcalendaring', dispname = data.name || data.email, status = data.role == 'ORGANIZER' ? 'ORGANIZER' : data.status; if (status) status = status.toLowerCase(); if (data.email) { tooltip = data.email; name = $('').attr({href: 'mailto:' + data.email, 'class': 'mailtolink', 'data-cutype': data.cutype}) if (status) tooltip += ' (' + rcmail.gettext('status' + status, context) + ')'; } else { name = $(''); } if (data['delegated-to']) tooltip = rcmail.gettext('libcalendaring.delegatedto') + ' ' + data['delegated-to']; else if (data['delegated-from']) tooltip = rcmail.gettext('libcalendaring.delegatedfrom') + ' ' + data['delegated-from']; return $('').append( $('').attr({'class': 'attendee ' + status, title: tooltip}).append(name.text(dispname)) ).html(); }; /** * */ rcube_libcalendaring.add_from_itip_mail = function(mime_id, task, status, dom_id) { // ask user to delete the declined event from the local calendar (#1670) var del = false; if (rcmail.env.rsvp_saved && status == 'declined') { del = confirm(rcmail.gettext('itip.declinedeleteconfirm')); } // open dialog for iTip delegation if (status == 'delegated') { rcube_libcalendaring.itip_delegate_dialog(function(data) { rcmail.http_post(task + '/itip-delegate', { _uid: rcmail.env.uid, _mbox: rcmail.env.mailbox, _part: mime_id, _to: data.to, _rsvp: data.rsvp ? 1 : 0, _comment: data.comment, _folder: data.target }, rcmail.set_busy(true, 'itip.savingdata')); }, $('#rsvp-'+dom_id+' .folder-select')); return false; } var noreply = 0, comment = ''; if (dom_id) { noreply = $('#noreply-'+dom_id+':checked').length ? 1 : 0; if (!noreply) comment = $('#reply-comment-'+dom_id).val(); } rcmail.http_post(task + '/mailimportitip', { _uid: rcmail.env.uid, _mbox: rcmail.env.mailbox, _part: mime_id, _folder: $('#itip-saveto').val(), _status: status, _del: del?1:0, _noreply: noreply, _comment: comment }, rcmail.set_busy(true, 'itip.savingdata')); return false; }; /** * Helper function to render the iTip delegation dialog * and trigger a callback function when submitted. */ rcube_libcalendaring.itip_delegate_dialog = function(callback, selector) { // show dialog for entering the delegatee address and comment var html = '
      ' + '
      ' + '
      ' + '' + '
      ' + '
      ' + '' + '
      ' + '
      ' + '' + '
      ' + '
      ' + (selector && selector.length ? selector.html() : '') + '
      ' + '
      '; var dialog, buttons = []; buttons.push({ text: rcmail.gettext('itipdelegated', 'itip'), click: function() { var doc = window.parent.document, delegatee = String($('#itip-delegate-to', doc).val()).replace(/(^\s+)|(\s+$)/, ''); if (delegatee != '' && rcube_check_email(delegatee, true)) { callback({ to: delegatee, rsvp: $('#itip-delegate-rsvp', doc).prop('checked'), comment: $('#itip-delegate-comment', doc).val(), target: $('#itip-saveto', doc).val() }); setTimeout(function() { dialog.dialog("close"); }, 500); } else { alert(rcmail.gettext('itip.delegateinvalidaddress')); $('#itip-delegate-to', doc).focus(); } } }); buttons.push({ text: rcmail.gettext('cancel', 'itip'), click: function() { dialog.dialog('close'); } }); dialog = rcmail.show_popup_dialog(html, rcmail.gettext('delegateinvitation', 'itip'), buttons, { width: 460, open: function(event, ui) { $(this).parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().addClass('mainaction'); $(this).find('#itip-saveto').val(''); // initialize autocompletion var ac_props, rcm = rcmail.is_framed() ? parent.rcmail : rcmail; if (rcmail.env.autocomplete_threads > 0) { ac_props = { threads: rcmail.env.autocomplete_threads, sources: rcmail.env.autocomplete_sources }; } rcm.init_address_input_events($(this).find('#itip-delegate-to').focus(), ac_props); rcm.env.recipients_delimiter = ''; }, close: function(event, ui) { rcm = rcmail.is_framed() ? parent.rcmail : rcmail; rcm.ksearch_blur(); $(this).remove(); } }); return dialog; }; /** * Show a menu for selecting the RSVP reply mode */ rcube_libcalendaring.itip_rsvp_recurring = function(btn, callback) { var mnu = $('
        ').addClass('popupmenu libcal-rsvp-replymode'); $.each(['all','current'/*,'future'*/], function(i, mode) { $('
      • ' + rcmail.get_label('rsvpmode'+mode, 'libcalendaring') + '') .addClass('ui-menu-item') .attr('rel', mode) .appendTo(mnu); }); var action = btn.attr('rel'); // open the mennu mnu.menu({ select: function(event, ui) { callback(action, ui.item.attr('rel')); } }) .appendTo(document.body) .position({ my: 'left top', at: 'left bottom+2', of: btn }) .data('action', action); setTimeout(function() { $(document).one('click', function() { mnu.menu('destroy'); mnu.remove(); }); }, 100); }; /** * */ rcube_libcalendaring.remove_from_itip = function(event, task, title) { if (confirm(rcmail.gettext('itip.deleteobjectconfirm').replace('$title', title))) { rcmail.http_post(task + '/itip-remove', event, rcmail.set_busy(true, 'itip.savingdata') ); } }; /** * */ rcube_libcalendaring.decline_attendee_reply = function(mime_id, task) { // show dialog for entering a comment and send to server var html = '
        ' + rcmail.gettext('itip.declineattendeeconfirm') + '
        ' + ''; var dialog, buttons = []; buttons.push({ text: rcmail.gettext('declineattendee', 'itip'), click: function() { rcmail.http_post(task + '/itip-decline-reply', { _uid: rcmail.env.uid, _mbox: rcmail.env.mailbox, _part: mime_id, _comment: $('#itip-decline-comment', window.parent.document).val() }, rcmail.set_busy(true, 'itip.savingdata')); dialog.dialog("close"); } }); buttons.push({ text: rcmail.gettext('cancel', 'itip'), click: function() { dialog.dialog('close'); } }); dialog = rcmail.show_popup_dialog(html, rcmail.gettext('declineattendee', 'itip'), buttons, { width: 460, open: function() { $(this).parent().find('.ui-button:not(.ui-dialog-titlebar-close)').first().addClass('mainaction'); $('#itip-decline-comment').focus(); } }); return false; }; /** * */ rcube_libcalendaring.fetch_itip_object_status = function(p) { p.mbox = rcmail.env.mailbox; p.message_uid = rcmail.env.uid; rcmail.http_post(p.task + '/itip-status', { data: p }); }; /** * */ rcube_libcalendaring.update_itip_object_status = function(p) { rcmail.env.rsvp_saved = p.saved; rcmail.env.itip_existing = p.existing; // hide all elements first $('#itip-buttons-'+p.id+' > div').hide(); $('#rsvp-'+p.id+' .folder-select').remove(); if (p.html) { // append/replace rsvp status display $('#loading-'+p.id).next('.rsvp-status').remove(); $('#loading-'+p.id).hide().after(p.html); } // enable/disable rsvp buttons if (p.action == 'rsvp') { $('#rsvp-'+p.id+' input.button').prop('disabled', false) .filter('.'+String(p.status||'unknown').toLowerCase()).prop('disabled', p.latest); } // show rsvp/import buttons (with calendar selector) $('#'+p.action+'-'+p.id).show().find('input.button').last().after(p.select); // highlight date if date change detected if (p.rescheduled) $('.calendar-eventdetails td.date').addClass('modified'); // show itip box appendix after replacing the given placeholders if (p.append && p.append.selector) { var elem = $(p.append.selector); if (p.append.replacements) { $.each(p.append.replacements, function(k, html) { elem.html(elem.html().replace(k, html)); }); } else if (p.append.html) { elem.html(p.append.html) } elem.show(); } }; /** * Callback from server after an iTip message has been processed */ rcube_libcalendaring.itip_message_processed = function(metadata) { if (metadata.after_action) { setTimeout(function(){ rcube_libcalendaring.itip_after_action(metadata.after_action); }, 1200); } else { rcube_libcalendaring.fetch_itip_object_status(metadata); } }; /** * After-action on iTip request message. Action types: * 0 - no action * 1 - move to Trash * 2 - delete the message * 3 - flag as deleted * folder_name - move the message to the specified folder */ rcube_libcalendaring.itip_after_action = function(action) { if (!action) { return; } var rc = rcmail.is_framed() ? parent.rcmail : rcmail; if (action === 2) { rc.permanently_remove_messages(); } else if (action === 3) { rc.mark_message('delete'); } else { rc.move_messages(action === 1 ? rc.env.trash_mailbox : action); } }; /** * Open the calendar preview for the current iTip event */ rcube_libcalendaring.open_itip_preview = function(url, msgref) { if (!rcmail.env.itip_existing) url += '&itip=' + escape(msgref); var win = rcmail.open_window(url); }; // extend jQuery (function($){ $.fn.serializeJSON = function(){ var json = {}; jQuery.map($(this).serializeArray(), function(n, i) { json[n['name']] = n['value']; }); return json; }; })(jQuery); /* libcalendaring plugin initialization */ window.rcmail && rcmail.addEventListener('init', function(evt) { if (rcmail.env.libcal_settings) { var libcal = new rcube_libcalendaring(rcmail.env.libcal_settings); rcmail.addEventListener('plugin.display_alarms', function(alarms){ libcal.display_alarms(alarms); }); } rcmail.addEventListener('plugin.update_itip_object_status', rcube_libcalendaring.update_itip_object_status) .addEventListener('plugin.fetch_itip_object_status', rcube_libcalendaring.fetch_itip_object_status) .addEventListener('plugin.itip_message_processed', rcube_libcalendaring.itip_message_processed); - - if (rcmail.env.action == 'get-attachment' && rcmail.gui_objects.attachmentframe) { - rcmail.gui_objects.messagepartframe = rcmail.gui_objects.attachmentframe; - rcmail.enable_command('image-scale', 'image-rotate', !!/^image\//.test(rcmail.env.mimetype)); - rcmail.register_command('print-attachment', function() { - var frame = rcmail.get_frame_window(rcmail.gui_objects.attachmentframe.id); - if (frame) frame.print(); - }, true); - } - - if (rcmail.env.action == 'get-attachment' && rcmail.env.attachment_download_url) { - rcmail.register_command('download-attachment', function() { - rcmail.location_href(rcmail.env.attachment_download_url, window); - }, true); - } }); diff --git a/plugins/libcalendaring/libcalendaring.php b/plugins/libcalendaring/libcalendaring.php index 21aeaf1f..4ae37166 100644 --- a/plugins/libcalendaring/libcalendaring.php +++ b/plugins/libcalendaring/libcalendaring.php @@ -1,1790 +1,1523 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class libcalendaring extends rcube_plugin { public $rc; public $timezone; public $gmt_offset; public $dst_active; public $timezone_offset; public $ical_parts = array(); public $ical_message; public $defaults = array( 'calendar_date_format' => "yyyy-MM-dd", 'calendar_date_short' => "M-d", 'calendar_date_long' => "MMM d yyyy", 'calendar_date_agenda' => "ddd MM-dd", 'calendar_time_format' => "HH:mm", 'calendar_first_day' => 1, 'calendar_first_hour' => 6, 'calendar_date_format_sets' => array( 'yyyy-MM-dd' => array('MMM d yyyy', 'M-d', 'ddd MM-dd'), 'dd-MM-yyyy' => array('d MMM yyyy', 'd-M', 'ddd dd-MM'), 'yyyy/MM/dd' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'), 'MM/dd/yyyy' => array('MMM d yyyy', 'M/d', 'ddd MM/dd'), 'dd/MM/yyyy' => array('d MMM yyyy', 'd/M', 'ddd dd/MM'), 'dd.MM.yyyy' => array('dd. MMM yyyy', 'd.M', 'ddd dd.MM.'), 'd.M.yyyy' => array('d. MMM yyyy', 'd.M', 'ddd d.MM.'), ), ); private static $instance; private $mail_ical_parser; /** * Singleton getter to allow direct access from other plugins */ public static function get_instance() { if (!self::$instance) { self::$instance = new libcalendaring(rcube::get_instance()->plugins); self::$instance->init_instance(); } return self::$instance; } /** * Initializes class properties */ public function init_instance() { $this->rc = rcube::get_instance(); // set user's timezone try { $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT')); } catch (Exception $e) { $this->timezone = new DateTimeZone('GMT'); } $now = new DateTime('now', $this->timezone); $this->gmt_offset = $now->getOffset(); $this->dst_active = $now->format('I'); $this->timezone_offset = $this->gmt_offset / 3600 - $this->dst_active; $this->add_texts('localization/', false); } /** * Required plugin startup method */ public function init() { self::$instance = $this; $this->rc = rcube::get_instance(); $this->init_instance(); // include client scripts and styles if ($this->rc->output) { // add hook to display alarms $this->add_hook('refresh', array($this, 'refresh')); $this->register_action('plugin.alarms', array($this, 'alarms_action')); $this->register_action('plugin.expand_attendee_group', array($this, 'expand_attendee_group')); } // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Startup hook */ public function startup($args) { if ($this->rc->output && $this->rc->output->type == 'html') { $this->rc->output->set_env('libcal_settings', $this->load_settings()); $this->include_script('libcalendaring.js'); $this->include_stylesheet($this->local_skin_path() . '/libcal.css'); $this->add_label( 'itipaccepted', 'itiptentative', 'itipdeclined', 'itipdelegated', 'expandattendeegroup', 'expandattendeegroupnodata', 'statusorganizer', 'statusaccepted', 'statusdeclined', 'statusdelegated', 'statusunknown', 'statusneeds-action', 'statustentative', 'statuscompleted', 'statusin-process', 'delegatedto', 'delegatedfrom', 'showmore' ); } if ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { $this->add_hook('message_load', array($this, 'mail_message_load')); } } } /** * Load iCalendar functions */ public static function get_ical() { $self = self::get_instance(); require_once __DIR__ . '/libvcalendar.php'; return new libvcalendar(); } /** * Load iTip functions */ public static function get_itip($domain = 'libcalendaring') { $self = self::get_instance(); require_once __DIR__ . '/lib/libcalendaring_itip.php'; return new libcalendaring_itip($self, $domain); } /** * Load recurrence computation engine */ public static function get_recurrence() { $self = self::get_instance(); require_once __DIR__ . '/lib/libcalendaring_recurrence.php'; return new libcalendaring_recurrence($self); } /** * Shift dates into user's current timezone * * @param mixed Any kind of a date representation (DateTime object, string or unix timestamp) * @return object DateTime object in user's timezone */ public function adjust_timezone($dt, $dateonly = false) { if (is_numeric($dt)) $dt = new DateTime('@'.$dt); else if (is_string($dt)) $dt = rcube_utils::anytodatetime($dt); if ($dt instanceof DateTime && !($dt->_dateonly || $dateonly)) { $dt->setTimezone($this->timezone); } return $dt; } /** * */ public function load_settings() { $this->date_format_defaults(); $settings = array(); $keys = array('date_format', 'time_format', 'date_short', 'date_long'); foreach ($keys as $key) { $settings[$key] = (string)$this->rc->config->get('calendar_' . $key, $this->defaults['calendar_' . $key]); $settings[$key] = str_replace('Y', 'y', $settings[$key]); } $settings['dates_long'] = str_replace(' yyyy', '[ yyyy]', $settings['date_long']) . "{ '—' " . $settings['date_long'] . '}'; $settings['first_day'] = (int)$this->rc->config->get('calendar_first_day', $this->defaults['calendar_first_day']); $settings['timezone'] = $this->timezone_offset; $settings['dst'] = $this->dst_active; // localization $settings['days'] = array( $this->rc->gettext('sunday'), $this->rc->gettext('monday'), $this->rc->gettext('tuesday'), $this->rc->gettext('wednesday'), $this->rc->gettext('thursday'), $this->rc->gettext('friday'), $this->rc->gettext('saturday') ); $settings['days_short'] = array( $this->rc->gettext('sun'), $this->rc->gettext('mon'), $this->rc->gettext('tue'), $this->rc->gettext('wed'), $this->rc->gettext('thu'), $this->rc->gettext('fri'), $this->rc->gettext('sat') ); $settings['months'] = array( $this->rc->gettext('longjan'), $this->rc->gettext('longfeb'), $this->rc->gettext('longmar'), $this->rc->gettext('longapr'), $this->rc->gettext('longmay'), $this->rc->gettext('longjun'), $this->rc->gettext('longjul'), $this->rc->gettext('longaug'), $this->rc->gettext('longsep'), $this->rc->gettext('longoct'), $this->rc->gettext('longnov'), $this->rc->gettext('longdec') ); $settings['months_short'] = array( $this->rc->gettext('jan'), $this->rc->gettext('feb'), $this->rc->gettext('mar'), $this->rc->gettext('apr'), $this->rc->gettext('may'), $this->rc->gettext('jun'), $this->rc->gettext('jul'), $this->rc->gettext('aug'), $this->rc->gettext('sep'), $this->rc->gettext('oct'), $this->rc->gettext('nov'), $this->rc->gettext('dec') ); $settings['today'] = $this->rc->gettext('today'); // define list of file types which can be displayed inline // same as in program/steps/mail/show.inc $settings['mimetypes'] = (array)$this->rc->config->get('client_mimetypes'); return $settings; } /** * Helper function to set date/time format according to config and user preferences */ private function date_format_defaults() { static $defaults = array(); // nothing to be done if (isset($defaults['date_format'])) return; $defaults['date_format'] = $this->rc->config->get('calendar_date_format', self::from_php_date_format($this->rc->config->get('date_format'))); $defaults['time_format'] = $this->rc->config->get('calendar_time_format', self::from_php_date_format($this->rc->config->get('time_format'))); // override defaults if ($defaults['date_format']) $this->defaults['calendar_date_format'] = $defaults['date_format']; if ($defaults['time_format']) $this->defaults['calendar_time_format'] = $defaults['time_format']; // derive format variants from basic date format $format_sets = $this->rc->config->get('calendar_date_format_sets', $this->defaults['calendar_date_format_sets']); if ($format_set = $format_sets[$this->defaults['calendar_date_format']]) { $this->defaults['calendar_date_long'] = $format_set[0]; $this->defaults['calendar_date_short'] = $format_set[1]; $this->defaults['calendar_date_agenda'] = $format_set[2]; } } /** * Compose a date string for the given event */ public function event_date_text($event, $tzinfo = false) { $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')) { return $fromto; } $duration = $event['start']->diff($event['end'])->format('s'); $this->date_format_defaults(); $date_format = self::to_php_date_format($this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format'])); $time_format = self::to_php_date_format($this->rc->config->get('calendar_time_format', $this->defaults['calendar_time_format'])); if ($event['allday']) { $fromto = $this->rc->format_date($event['start'], $date_format); if (($todate = $this->rc->format_date($event['end'], $date_format)) != $fromto) $fromto .= ' - ' . $todate; } else if ($duration < 86400 && $event['start']->format('d') == $event['end']->format('d')) { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $time_format); } else { $fromto = $this->rc->format_date($event['start'], $date_format) . ' ' . $this->rc->format_date($event['start'], $time_format) . ' - ' . $this->rc->format_date($event['end'], $date_format) . ' ' . $this->rc->format_date($event['end'], $time_format); } // add timezone information if ($tzinfo && ($tzname = $this->timezone->getName())) { $fromto .= ' (' . strtr($tzname, '_', ' ') . ')'; } return $fromto; } /** * Render HTML form for alarm configuration */ public function alarm_select($attrib, $alarm_types, $absolute_time = true) { unset($attrib['name']); $input_value = new html_inputfield(array('name' => 'alarmvalue[]', 'class' => 'edit-alarm-value form-control', 'size' => 3)); $input_date = new html_inputfield(array('name' => 'alarmdate[]', 'class' => 'edit-alarm-date form-control', 'size' => 10)); $input_time = new html_inputfield(array('name' => 'alarmtime[]', 'class' => 'edit-alarm-time form-control', 'size' => 6)); $select_type = new html_select(array('name' => 'alarmtype[]', 'class' => 'edit-alarm-type form-control', 'id' => $attrib['id'])); $select_offset = new html_select(array('name' => 'alarmoffset[]', 'class' => 'edit-alarm-offset form-control')); $select_related = new html_select(array('name' => 'alarmrelated[]', 'class' => 'edit-alarm-related form-control')); $object_type = $attrib['_type'] ?: 'event'; $select_type->add($this->gettext('none'), ''); foreach ($alarm_types as $type) $select_type->add($this->gettext(strtolower("alarm{$type}option")), $type); foreach (array('-M','-H','-D','+M','+H','+D') as $trigger) $select_offset->add($this->gettext('trigger' . $trigger), $trigger); $select_offset->add($this->gettext('trigger0'), '0'); if ($absolute_time) $select_offset->add($this->gettext('trigger@'), '@'); $select_related->add($this->gettext('relatedstart'), 'start'); $select_related->add($this->gettext('relatedend' . $object_type), 'end'); // pre-set with default values from user settings $preset = self::parse_alarm_value($this->rc->config->get('calendar_default_alarm_offset', '-15M')); $hidden = array('style' => 'display:none'); $html = html::span('edit-alarm-set', $select_type->show($this->rc->config->get('calendar_default_alarm_type', '')) . ' ' . html::span(array('class' => 'edit-alarm-values', 'style' => 'display:none'), $input_value->show($preset[0]) . ' ' . $select_offset->show($preset[1]) . ' ' . $select_related->show() . ' ' . $input_date->show('', $hidden) . ' ' . $input_time->show('', $hidden) ) ); // TODO: support adding more alarms #$html .= html::a(array('href' => '#', 'id' => 'edit-alam-add', 'title' => $this->gettext('addalarm')), # $attrib['addicon'] ? html::img(array('src' => $attrib['addicon'], 'alt' => 'add')) : '(+)'); return $html; } /** * Get a list of email addresses of the given user (from login and identities) * * @param string User Email (default to current user) * @return array Email addresses related to the user */ public function get_user_emails($user = null) { static $_emails = array(); if (empty($user)) { $user = $this->rc->user->get_username(); } // return cached result if (is_array($_emails[$user])) { return $_emails[$user]; } $emails = array($user); $plugin = $this->rc->plugins->exec_hook('calendar_user_emails', array('emails' => $emails)); $emails = array_map('strtolower', $plugin['emails']); // add all emails from the current user's identities if (!$plugin['abort'] && ($user == $this->rc->user->get_username())) { foreach ($this->rc->user->list_emails() as $identity) { $emails[] = strtolower($identity['email']); } } $_emails[$user] = array_unique($emails); return $_emails[$user]; } /** * Set the given participant status to the attendee matching the current user's identities * * @param array Hash array with event struct * @param string The PARTSTAT value to set * @return mixed Email address of the updated attendee or False if none matching found */ public function set_partstat(&$event, $status, $recursive = true) { $success = false; $emails = $this->get_user_emails(); foreach ((array)$event['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $event['attendees'][$i]['status'] = strtoupper($status); $success = $attendee['email']; } } // apply partstat update to each existing exception if ($event['recurrence'] && is_array($event['recurrence']['EXCEPTIONS'])) { foreach ($event['recurrence']['EXCEPTIONS'] as $i => $exception) { $this->set_partstat($event['recurrence']['EXCEPTIONS'][$i], $status, false); } // set link to top-level exceptions $event['exceptions'] = &$event['recurrence']['EXCEPTIONS']; } return $success; } /********* Alarms handling *********/ /** * Helper function to convert alarm trigger strings * into two-field values (e.g. "-45M" => 45, "-M") */ public static function parse_alarm_value($val) { if ($val[0] == '@') { return array(new DateTime($val)); } else if (preg_match('/([+-]?)P?(T?\d+[HMSDW])+/', $val, $m) && preg_match_all('/T?(\d+)([HMSDW])/', $val, $m2, PREG_SET_ORDER)) { if ($m[1] == '') $m[1] = '+'; foreach ($m2 as $seg) { $prefix = $seg[2] == 'D' || $seg[2] == 'W' ? 'P' : 'PT'; if ($seg[1] > 0) { // ignore zero values // convert seconds to minutes if ($seg[2] == 'S') { $seg[2] = 'M'; $seg[1] = max(1, round($seg[1]/60)); } return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]); } } // return zero value nevertheless return array($seg[1], $m[1].$seg[2], $m[1].$seg[1].$seg[2], $m[1].$prefix.$seg[1].$seg[2]); } return false; } /** * Convert the alarms list items to be processed on the client */ public static function to_client_alarms($valarms) { return array_map(function($alarm){ if ($alarm['trigger'] instanceof DateTime) { $alarm['trigger'] = '@' . $alarm['trigger']->format('U'); } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[2]; } return $alarm; }, (array)$valarms); } /** * Process the alarms values submitted by the client */ public static function from_client_alarms($valarms) { return array_map(function($alarm){ if ($alarm['trigger'][0] == '@') { try { $alarm['trigger'] = new DateTime($alarm['trigger']); $alarm['trigger']->setTimezone(new DateTimeZone('UTC')); } catch (Exception $e) { /* handle this ? */ } } else if ($trigger = libcalendaring::parse_alarm_value($alarm['trigger'])) { $alarm['trigger'] = $trigger[3]; } return $alarm; }, (array)$valarms); } /** * Render localized text for alarm settings */ public static function alarms_text($alarms) { if (is_array($alarms) && is_array($alarms[0])) { $texts = array(); foreach ($alarms as $alarm) { if ($text = self::alarm_text($alarm)) $texts[] = $text; } return join(', ', $texts); } else { return self::alarm_text($alarms); } } /** * Render localized text for a single alarm property */ public static function alarm_text($alarm) { if (is_string($alarm)) { list($trigger, $action) = explode(':', $alarm); } else { $trigger = $alarm['trigger']; $action = $alarm['action']; $related = $alarm['related']; } $text = ''; $rcube = rcube::get_instance(); switch ($action) { case 'EMAIL': $text = $rcube->gettext('libcalendaring.alarmemail'); break; case 'DISPLAY': $text = $rcube->gettext('libcalendaring.alarmdisplay'); break; case 'AUDIO': $text = $rcube->gettext('libcalendaring.alarmaudio'); break; } if ($trigger instanceof DateTime) { $text .= ' ' . $rcube->gettext(array( 'name' => 'libcalendaring.alarmat', 'vars' => array('datetime' => $rcube->format_date($trigger)) )); } else if (preg_match('/@(\d+)/', $trigger, $m)) { $text .= ' ' . $rcube->gettext(array( 'name' => 'libcalendaring.alarmat', 'vars' => array('datetime' => $rcube->format_date($m[1])) )); } else if ($val = self::parse_alarm_value($trigger)) { $r = strtoupper($related ?: 'start') == 'END' ? 'end' : ''; // TODO: for all-day events say 'on date of event at XX' ? if ($val[0] == 0) { $text .= ' ' . $rcube->gettext('libcalendaring.triggerattime' . $r); } else { $label = 'libcalendaring.trigger' . $r . $val[1]; $text .= ' ' . intval($val[0]) . ' ' . $rcube->gettext($label); } } else { return false; } return $text; } /** * Get the next alarm (time & action) for the given event * * @param array Record data * @return array Hash array with alarm time/type or null if no alarms are configured */ public static function get_next_alarm($rec, $type = 'event') { if (!($rec['valarms'] || $rec['alarms']) || $rec['cancelled'] || $rec['status'] == 'CANCELLED') return null; if ($type == 'task') { $timezone = self::get_instance()->timezone; if ($rec['startdate']) $rec['start'] = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?: '12:00'), $timezone); if ($rec['date']) $rec[($rec['start'] ? 'end' : 'start')] = new DateTime($rec['date'] . ' ' . ($rec['time'] ?: '12:00'), $timezone); } if (!$rec['end']) $rec['end'] = $rec['start']; // support legacy format if (!$rec['valarms']) { list($trigger, $action) = explode(':', $rec['alarms'], 2); if ($alarm = self::parse_alarm_value($trigger)) { $rec['valarms'] = array(array('action' => $action, 'trigger' => $alarm[3] ?: $alarm[0])); } } $expires = new DateTime('now - 12 hours'); $alarm_id = $rec['id']; // alarm ID eq. record ID by default to keep backwards compatibility // handle multiple alarms $notify_at = null; foreach ($rec['valarms'] as $alarm) { $notify_time = null; if ($alarm['trigger'] instanceof DateTime) { $notify_time = $alarm['trigger']; } else if (is_string($alarm['trigger'])) { $refdate = $alarm['related'] == 'END' ? $rec['end'] : $rec['start']; // abort if no reference date is available to compute notification time if (!is_a($refdate, 'DateTime')) continue; // TODO: for all-day events, take start @ 00:00 as reference date ? try { $interval = new DateInterval(trim($alarm['trigger'], '+-')); $interval->invert = $alarm['trigger'][0] == '-'; $notify_time = clone $refdate; $notify_time->add($interval); } catch (Exception $e) { rcube::raise_error($e, true); continue; } } if ($notify_time && (!$notify_at || ($notify_time > $notify_at && $notify_time > $expires))) { $notify_at = $notify_time; $action = $alarm['action']; $alarm_prop = $alarm; // generate a unique alarm ID if multiple alarms are set if (count($rec['valarms']) > 1) { $alarm_id = substr(md5($rec['id']), 0, 16) . '-' . $notify_at->format('Ymd\THis'); } } } return !$notify_at ? null : array( 'time' => $notify_at->format('U'), 'action' => $action ? strtoupper($action) : 'DISPLAY', 'id' => $alarm_id, 'prop' => $alarm_prop, ); } /** * Handler for keep-alive requests * This will check for pending notifications and pass them to the client */ public function refresh($attr) { // collect pending alarms from all providers (e.g. calendar, tasks) $plugin = $this->rc->plugins->exec_hook('pending_alarms', array( 'time' => time(), 'alarms' => array(), )); if (!$plugin['abort'] && !empty($plugin['alarms'])) { // make sure texts and env vars are available on client $this->add_texts('localization/', true); $this->rc->output->add_label('close'); $this->rc->output->set_env('snooze_select', $this->snooze_select()); $this->rc->output->command('plugin.display_alarms', $this->_alarms_output($plugin['alarms'])); } } /** * Handler for alarm dismiss/snooze requests */ public function alarms_action() { // $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $data['ids'] = explode(',', $data['id']); $plugin = $this->rc->plugins->exec_hook('dismiss_alarms', $data); if ($plugin['success']) $this->rc->output->show_message('successfullysaved', 'confirmation'); else $this->rc->output->show_message('calendar.errorsaving', 'error'); } /** * Generate reduced and streamlined output for pending alarms */ private function _alarms_output($alarms) { $out = array(); foreach ($alarms as $alarm) { $out[] = array( 'id' => $alarm['id'], 'start' => $alarm['start'] ? $this->adjust_timezone($alarm['start'])->format('c') : '', 'end' => $alarm['end'] ? $this->adjust_timezone($alarm['end'])->format('c') : '', 'allDay' => $alarm['allday'] == 1, 'action' => $alarm['action'], 'title' => $alarm['title'], 'location' => $alarm['location'], 'calendar' => $alarm['calendar'], ); } return $out; } /** * Render a dropdown menu to choose snooze time */ private function snooze_select($attrib = array()) { $steps = array( 5 => 'repeatinmin', 10 => 'repeatinmin', 15 => 'repeatinmin', 20 => 'repeatinmin', 30 => 'repeatinmin', 60 => 'repeatinhr', 120 => 'repeatinhrs', 1440 => 'repeattomorrow', 10080 => 'repeatinweek', ); $items = array(); foreach ($steps as $n => $label) { $items[] = html::tag('li', null, html::a(array('href' => "#" . ($n * 60), 'class' => 'active'), $this->gettext(array('name' => $label, 'vars' => array('min' => $n % 60, 'hrs' => intval($n / 60)))))); } return html::tag('ul', $attrib + array('class' => 'toolbarmenu'), join("\n", $items), html::$common_attrib); } /********* Recurrence rules handling ********/ /** * Render localized text describing the recurrence rule of an event */ public function recurrence_text($rrule) { $limit = 10; $exdates = array(); $format = $this->rc->config->get('calendar_date_format', $this->defaults['calendar_date_format']); $format = self::to_php_date_format($format); $format_fn = function($dt) use ($format) { return rcmail::get_instance()->format_date($dt, $format); }; if (is_array($rrule['EXDATE']) && !empty($rrule['EXDATE'])) { $exdates = array_map($format_fn, $rrule['EXDATE']); } if (empty($rrule['FREQ']) && !empty($rrule['RDATE'])) { $rdates = array_map($format_fn, $rrule['RDATE']); if (!empty($exdates)) { $rdates = array_diff($rdates, $exdates); } if (count($rdates) > $limit) { $rdates = array_slice($rdates, 0, $limit); $more = true; } return $this->gettext('ondate') . ' ' . join(', ', $rdates) . ($more ? '...' : ''); } $output = sprintf('%s %d ', $this->gettext('every'), $rrule['INTERVAL'] ?: 1); switch ($rrule['FREQ']) { case 'DAILY': $output .= $this->gettext('days'); break; case 'WEEKLY': $output .= $this->gettext('weeks'); break; case 'MONTHLY': $output .= $this->gettext('months'); break; case 'YEARLY': $output .= $this->gettext('years'); break; } if ($rrule['COUNT']) { $until = $this->gettext(array('name' => 'forntimes', 'vars' => array('nr' => $rrule['COUNT']))); } else if ($rrule['UNTIL']) { $until = $this->gettext('recurrencend') . ' ' . $this->rc->format_date($rrule['UNTIL'], $format); } else { $until = $this->gettext('forever'); } $output .= ', ' . $until; if (!empty($exdates)) { if (count($exdates) > $limit) { $exdates = array_slice($exdates, 0, $limit); $more = true; } $output = '; ' . $this->gettext('except') . ' ' . join(', ', $exdates) . ($more ? '...' : ''); } return $output; } /** * Generate the form for recurrence settings */ public function recurrence_form($attrib = array()) { switch ($attrib['part']) { // frequency selector case 'frequency': $select = new html_select(array('name' => 'frequency', 'id' => 'edit-recurrence-frequency', 'class' => 'form-control')); $select->add($this->gettext('never'), ''); $select->add($this->gettext('daily'), 'DAILY'); $select->add($this->gettext('weekly'), 'WEEKLY'); $select->add($this->gettext('monthly'), 'MONTHLY'); $select->add($this->gettext('yearly'), 'YEARLY'); $select->add($this->gettext('rdate'), 'RDATE'); $html = html::label(array('for' => 'edit-recurrence-frequency', 'class' => 'col-form-label col-sm-2'), $this->gettext('frequency')) . html::div('col-sm-10', $select->show('')); break; // daily recurrence case 'daily': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-daily')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-daily', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10', $select->show(1) . html::span('label-after', $this->gettext('days')))); break; // weekly recurrence form case 'weekly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-weekly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-weekly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10', $select->show(1) . html::span('label-after', $this->gettext('weeks')))); // weekday selection $daymap = array('sun','mon','tue','wed','thu','fri','sat'); $checkbox = new html_checkbox(array('name' => 'byday', 'class' => 'edit-recurrence-weekly-byday')); $first = $this->rc->config->get('calendar_first_day', 1); for ($weekdays = '', $j = $first; $j <= $first+6; $j++) { $d = $j % 7; $weekdays .= html::label(array('class' => 'weekday'), $checkbox->show('', array('value' => strtoupper(substr($daymap[$d], 0, 2)))) . $this->gettext($daymap[$d]) ) . ' '; } $html .= html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), $this->gettext('bydays')) . html::div('col-sm-10', $weekdays)); break; // monthly recurrence form case 'monthly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-monthly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-monthly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10', $select->show(1) . html::span('label-after', $this->gettext('months')))); $checkbox = new html_checkbox(array('name' => 'bymonthday', 'class' => 'edit-recurrence-monthly-bymonthday')); for ($monthdays = '', $d = 1; $d <= 31; $d++) { $monthdays .= html::label(array('class' => 'monthday'), $checkbox->show('', array('value' => $d)) . $d); $monthdays .= $d % 7 ? ' ' : html::br(); } // rule selectors $radio = new html_radiobutton(array('name' => 'repeatmode', 'class' => 'edit-recurrence-monthly-mode')); $table = new html_table(array('cols' => 2, 'border' => 0, 'cellpadding' => 0, 'class' => 'formtable')); $table->add('label', html::label(null, $radio->show('BYMONTHDAY', array('value' => 'BYMONTHDAY')) . ' ' . $this->gettext('each'))); $table->add(null, $monthdays); $table->add('label', html::label(null, $radio->show('', array('value' => 'BYDAY')) . ' ' . $this->gettext('onevery'))); $table->add(null, $this->rrule_selectors($attrib['part'])); $html .= html::div($attrib, $table->show()); break; // annually recurrence form case 'yearly': $select = $this->interval_selector(array('name' => 'interval', 'class' => 'edit-recurrence-interval form-control', 'id' => 'edit-recurrence-interval-yearly')); $html = html::div($attrib, html::label(array('for' => 'edit-recurrence-interval-yearly', 'class' => 'col-form-label col-sm-2'), $this->gettext('every')) . html::div('col-sm-10', $select->show(1) . html::span('label-after', $this->gettext('years')))); // month selector $monthmap = array('','jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'); $checkbox = new html_checkbox(array('name' => 'bymonth', 'class' => 'edit-recurrence-yearly-bymonth')); for ($months = '', $m = 1; $m <= 12; $m++) { $months .= html::label(array('class' => 'month'), $checkbox->show(null, array('value' => $m)) . $this->gettext($monthmap[$m])); $months .= $m % 4 ? ' ' : html::br(); } $html .= html::div($attrib + array('id' => 'edit-recurrence-yearly-bymonthblock'), $months); // day rule selection $html .= html::div($attrib, html::label(array('col-form-label col-sm-2'), $this->gettext('onevery')) . html::div('col-sm-10', $this->rrule_selectors($attrib['part'], '---'))); break; // end of recurrence form case 'until': $radio = new html_radiobutton(array('name' => 'repeat', 'class' => 'edit-recurrence-until')); $select = $this->interval_selector(array('name' => 'times', 'id' => 'edit-recurrence-repeat-times', 'class' => 'form-control')); $input = new html_inputfield(array('name' => 'untildate', 'id' => 'edit-recurrence-enddate', 'size' => "10", 'class' => 'form-control')); $html = html::div('line first', html::label(null, $radio->show('', array('value' => '', 'id' => 'edit-recurrence-repeat-forever')) . ' ' . $this->gettext('forever')) ); $forntimes = $this->gettext(array( 'name' => 'forntimes', 'vars' => array('nr' => '%s')) ); $html .= html::div('line', $radio->show('', array('value' => 'count', 'id' => 'edit-recurrence-repeat-count', 'aria-label' => sprintf($forntimes, 'N'))) . ' ' . sprintf($forntimes, $select->show(1)) ); $html .= html::div('line', $radio->show('', array('value' => 'until', 'id' => 'edit-recurrence-repeat-until', 'aria-label' => $this->gettext('untilenddate'))) . ' ' . $this->gettext('untildate') . ' ' . $input->show('', array('aria-label' => $this->gettext('untilenddate'))) ); $html = html::div($attrib, html::label(array('class' => 'col-form-label col-sm-2'), ucfirst($this->gettext('recurrencend'))) . $html); break; case 'rdate': $ul = html::tag('ul', array('id' => 'edit-recurrence-rdates'), ''); $input = new html_inputfield(array('name' => 'rdate', 'id' => 'edit-recurrence-rdate-input', 'size' => "10", 'class' => 'form-control')); $button = new html_inputfield(array('type' => 'button', 'class' => 'button add', 'value' => $this->gettext('addrdate'))); $html .= html::div($attrib, $ul . html::div('inputform', $input->show() . $button->show())); break; } return $html; } /** * Input field for interval selection */ private function interval_selector($attrib) { $select = new html_select($attrib); $select->add(range(1,30), range(1,30)); return $select; } /** * Drop-down menus for recurrence rules like "each last sunday of" */ private function rrule_selectors($part, $noselect = null) { // rule selectors $select_prefix = new html_select(array('name' => 'bydayprefix', 'id' => "edit-recurrence-$part-prefix", 'class' => 'form-control')); if ($noselect) $select_prefix->add($noselect, ''); $select_prefix->add(array( $this->gettext('first'), $this->gettext('second'), $this->gettext('third'), $this->gettext('fourth'), $this->gettext('last') ), array(1, 2, 3, 4, -1)); $select_wday = new html_select(array('name' => 'byday', 'id' => "edit-recurrence-$part-byday", 'class' => 'form-control')); if ($noselect) $select_wday->add($noselect, ''); $daymap = array('sunday','monday','tuesday','wednesday','thursday','friday','saturday'); $first = $this->rc->config->get('calendar_first_day', 1); for ($j = $first; $j <= $first+6; $j++) { $d = $j % 7; $select_wday->add($this->gettext($daymap[$d]), strtoupper(substr($daymap[$d], 0, 2))); } return $select_prefix->show() . ' ' . $select_wday->show(); } /** * Convert the recurrence settings to be processed on the client */ public function to_client_recurrence($recurrence, $allday = false) { if ($recurrence['UNTIL']) { $recurrence['UNTIL'] = $this->adjust_timezone($recurrence['UNTIL'], $allday)->format('c'); } // format RDATE values if (is_array($recurrence['RDATE'])) { $libcal = $this; $recurrence['RDATE'] = array_map(function($rdate) use ($libcal) { return $libcal->adjust_timezone($rdate, true)->format('c'); }, $recurrence['RDATE']); } unset($recurrence['EXCEPTIONS']); return $recurrence; } /** * Process the alarms values submitted by the client */ public function from_client_recurrence($recurrence, $start = null) { if (is_array($recurrence) && !empty($recurrence['UNTIL'])) { $recurrence['UNTIL'] = new DateTime($recurrence['UNTIL'], $this->timezone); } if (is_array($recurrence) && is_array($recurrence['RDATE'])) { $tz = $this->timezone; $recurrence['RDATE'] = array_map(function($rdate) use ($tz, $start) { try { $dt = new DateTime($rdate, $tz); if (is_a($start, 'DateTime')) $dt->setTime($start->format('G'), $start->format('i')); return $dt; } catch (Exception $e) { return null; } }, $recurrence['RDATE']); } return $recurrence; } - /********* Attachments handling *********/ - - /** - * Handler for attachment uploads - */ - public function attachment_upload($session_key, $id_prefix = '') - { - // Upload progress update - if (!empty($_GET['_progress'])) { - $this->rc->upload_progress(); - } - - $recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); - $uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC); - - if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) { - $_SESSION[$session_key] = array(); - $_SESSION[$session_key]['id'] = $recid; - $_SESSION[$session_key]['attachments'] = array(); - } - - // clear all stored output properties (like scripts and env vars) - $this->rc->output->reset(); - - if (is_array($_FILES['_attachments']['tmp_name'])) { - foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) { - // Process uploaded attachment if there is no error - $err = $_FILES['_attachments']['error'][$i]; - - if (!$err) { - $filename = $_FILES['_attachments']['name'][$i]; - $attachment = array( - 'path' => $filepath, - 'size' => $_FILES['_attachments']['size'][$i], - 'name' => $filename, - 'mimetype' => rcube_mime::file_content_type($filepath, $filename, $_FILES['_attachments']['type'][$i]), - 'group' => $recid, - ); - - $attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment); - } - - if (!$err && $attachment['status'] && !$attachment['abort']) { - $id = $attachment['id']; - - // store new attachment in session - unset($attachment['status'], $attachment['abort']); - $this->rc->session->append($session_key . '.attachments', $id, $attachment); - - if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) { - $button = html::img(array( - 'src' => $icon, - 'alt' => $this->rc->gettext('delete') - )); - } - else if ($_SESSION[$session_key . '_textbuttons']) { - $button = rcube::Q($this->rc->gettext('delete')); - } - else { - $button = ''; - } - - $link_content = sprintf('%s(%s)', - rcube::Q($attachment['name']), $this->rc->show_bytes($attachment['size'])); - - $delete_link = html::a(array( - 'href' => "#delete", - 'class' => 'delete', - 'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", rcmail_output::JS_OBJECT_NAME, $id), - 'title' => $this->rc->gettext('delete'), - 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], - ), $button); - - $content_link = html::a(array( - 'href' => "#load", - 'class' => 'filename', - 'onclick' => 'return false', // sprintf("return %s.command('load-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), - ), $link_content); - - $content .= $_SESSION[$session_key . '_icon_pos'] == 'left' ? $delete_link.$content_link : $content_link.$delete_link; - - $this->rc->output->command('add2attachment_list', "rcmfile$id", array( - 'html' => $content, - 'name' => $attachment['name'], - 'mimetype' => $attachment['mimetype'], - 'classname' => 'no-menu ' . rcube_utils::file2class($attachment['mimetype'], $attachment['name']), - 'complete' => true - ), $uploadid); - } - else { // upload failed - if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { - $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( - 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); - } - else if ($attachment['error']) { - $msg = $attachment['error']; - } - else { - $msg = $this->rc->gettext('fileuploaderror'); - } - - $this->rc->output->command('display_message', $msg, 'error'); - $this->rc->output->command('remove_from_attachment_list', $uploadid); - } - } - } - else if ($_SERVER['REQUEST_METHOD'] == 'POST') { - // if filesize exceeds post_max_size then $_FILES array is empty, - // show filesizeerror instead of fileuploaderror - if ($maxsize = ini_get('post_max_size')) - $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( - 'size' => $this->rc->show_bytes(parse_bytes($maxsize))))); - else - $msg = $this->rc->gettext('fileuploaderror'); - - $this->rc->output->command('display_message', $msg, 'error'); - $this->rc->output->command('remove_from_attachment_list', $uploadid); - } - - $this->rc->output->send('iframe'); - } - - - /** - * Deliver an event/task attachment to the client - * (similar as in Roundcube core program/steps/mail/get.inc) - */ - public function attachment_get($attachment) - { - ob_end_clean(); - - if ($attachment && $attachment['body']) { - // allow post-processing of the attachment body - $part = new rcube_message_part; - $part->filename = $attachment['name']; - $part->size = $attachment['size']; - $part->mimetype = $attachment['mimetype']; - - $plugin = $this->rc->plugins->exec_hook('message_part_get', array( - 'body' => $attachment['body'], - 'mimetype' => strtolower($attachment['mimetype']), - 'download' => !empty($_GET['_download']), - 'part' => $part, - )); - - if ($plugin['abort']) - exit; - - $mimetype = $plugin['mimetype']; - list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); - - $browser = $this->rc->output->browser; - - // send download headers - if ($plugin['download']) { - header("Content-Type: application/octet-stream"); - if ($browser->ie) - header("Content-Type: application/force-download"); - } - else if ($ctype_primary == 'text') { - header("Content-Type: text/$ctype_secondary"); - } - else { - header("Content-Type: $mimetype"); - header("Content-Transfer-Encoding: binary"); - } - - // display page, @TODO: support text/plain (and maybe some other text formats) - if ($mimetype == 'text/html' && empty($_GET['_download'])) { - $OUTPUT = new rcmail_html_page(); - // @TODO: use washtml on $body - $OUTPUT->write($plugin['body']); - } - else { - // don't kill the connection if download takes more than 30 sec. - @set_time_limit(0); - - $filename = $attachment['name']; - $filename = preg_replace('[\r\n]', '', $filename); - - if ($browser->ie && $browser->ver < 7) - $filename = rawurlencode(abbreviate_string($filename, 55)); - else if ($browser->ie) - $filename = rawurlencode($filename); - else - $filename = addcslashes($filename, '"'); - - $disposition = !empty($_GET['_download']) ? 'attachment' : 'inline'; - header("Content-Disposition: $disposition; filename=\"$filename\""); - - echo $plugin['body']; - } - - exit; - } - - // if we arrive here, the requested part was not found - header('HTTP/1.1 404 Not Found'); - exit; - } - - /** - * Show "loading..." page in attachment iframe - */ - public function attachment_loading_page() - { - $url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']); - $message = $this->rc->gettext('loadingdata'); - - header('Content-Type: text/html; charset=' . RCUBE_CHARSET); - print "\n\n" - . '' . "\n" - . '' . "\n" - . "\n\n$message\n\n"; - exit; - } - - /** - * Template object for attachment display frame - */ - public function attachment_frame($attrib = array()) - { - $mimetype = strtolower($this->attachment['mimetype']); - list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); - - $attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']); - - $this->rc->output->add_gui_object('attachmentframe', $attrib['id']); - - return html::iframe($attrib); - } - - /** - * - */ - public function attachment_header($attrib = array()) - { - $rcmail = rcmail::get_instance(); - $dl_link = strtolower($attrib['downloadlink']) == 'true'; - $dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET); - - $table = new html_table(array('cols' => $dl_link ? 3 : 2)); - - if (!empty($this->attachment['name'])) { - $table->add('title', rcube::Q($this->rc->gettext('filename'))); - $table->add('header', rcube::Q($this->attachment['name'])); - if ($dl_link) { - $table->add('download-link', html::a($dl_url, rcube::Q($this->rc->gettext('download')))); - } - } - - if (!empty($this->attachment['mimetype'])) { - $table->add('title', rcube::Q($this->rc->gettext('type'))); - $table->add('header', rcube::Q($this->attachment['mimetype'])); - } - - if (!empty($this->attachment['size'])) { - $table->add('title', rcube::Q($this->rc->gettext('filesize'))); - $table->add('header', rcube::Q($this->rc->show_bytes($this->attachment['size']))); - } - - $this->rc->output->set_env('attachment_download_url', $dl_url); - - return $table->show($attrib); - } - - /********* iTip message detection *********/ /** * Check mail message structure of there are .ics files attached */ public function mail_message_load($p) { $this->ical_message = $p['object']; $itip_part = null; // check all message parts for .ics files foreach ((array)$this->ical_message->mime_parts as $part) { if (self::part_is_vcalendar($part, $this->ical_message)) { if ($part->ctype_parameters['method']) $itip_part = $part->mime_id; else $this->ical_parts[] = $part->mime_id; } } // priorize part with method parameter if ($itip_part) { $this->ical_parts = array($itip_part); } } /** * Getter for the parsed iCal objects attached to the current email message * * @return object libvcalendar parser instance with the parsed objects */ public function get_mail_ical_objects() { // create parser and load ical objects if (!$this->mail_ical_parser) { $this->mail_ical_parser = $this->get_ical(); foreach ($this->ical_parts as $mime_id) { $part = $this->ical_message->mime_parts[$mime_id]; $charset = $part->ctype_parameters['charset'] ?: RCUBE_CHARSET; $this->mail_ical_parser->import($this->ical_message->get_part_body($mime_id, true), $charset); // check if the parsed object is an instance of a recurring event/task array_walk($this->mail_ical_parser->objects, 'libcalendaring::identify_recurrence_instance'); // stop on the part that has an iTip method specified if (count($this->mail_ical_parser->objects) && $this->mail_ical_parser->method) { $this->mail_ical_parser->message_date = $this->ical_message->headers->date; $this->mail_ical_parser->mime_id = $mime_id; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($this->ical_message->headers->from, 1, true, null, true); $this->mail_ical_parser->sender = !empty($from) ? $from[1] : ''; if (!empty($this->mail_ical_parser->sender)) { foreach ($this->mail_ical_parser->objects as $i => $object) { $this->mail_ical_parser->objects[$i]['_sender'] = $this->mail_ical_parser->sender; $this->mail_ical_parser->objects[$i]['_sender_utf'] = rcube_utils::idn_to_utf8($this->mail_ical_parser->sender); } } break; } } } return $this->mail_ical_parser; } /** * Read the given mime message from IMAP and parse ical data * * @param string Mailbox name * @param string Message UID * @param string Message part ID and object index (e.g. '1.2:0') * @param string Object type filter (optional) * * @return array Hash array with the parsed iCal */ public function mail_get_itip_object($mbox, $uid, $mime_id, $type = null) { $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($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); $parser = $this->get_ical(); if ($part->ctype_parameters['charset']) { $charset = $part->ctype_parameters['charset']; } if ($part) { $objects = $parser->import($part, $charset); } } // successfully parsed events/tasks? if (!empty($objects) && ($object = $objects[$index]) && (!$type || $object['_type'] == $type)) { if ($parser->method) $object['_method'] = $parser->method; // store the message's sender address for comparisons $from = rcube_mime::decode_address_list($headers->from, 1, true, null, true); $object['_sender'] = !empty($from) ? $from[1] : ''; $object['_sender_utf'] = rcube_utils::idn_to_utf8($object['_sender']); // check if this is an instance of a recurring event/task self::identify_recurrence_instance($object); return $object; } return null; } /** * Checks if specified message part is a vcalendar data * * @param rcube_message_part Part object * @param rcube_message Message object * * @return boolean True if part is of type vcard */ public static function part_is_vcalendar($part, $message = null) { // First check if the message is "valid" (i.e. not multipart/report) if ($message) { $level = explode('.', $part->mime_id); while (array_pop($level) !== null) { $parent = $message->mime_parts[join('.', $level) ?: 0]; if ($parent->mimetype == 'multipart/report') { return false; } } } 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)) ); } /** * Single occourrences of recurring events are identified by their RECURRENCE-ID property * in iCal which is represented as 'recurrence_date' in our internal data structure. * * Check if such a property exists and derive the '_instance' identifier and '_savemode' * attributes which are used in the storage backend to identify the nested exception item. */ public static function identify_recurrence_instance(&$object) { // for savemode=all, remove recurrence instance identifiers if (!empty($object['_savemode']) && $object['_savemode'] == 'all' && $object['recurrence']) { unset($object['_instance'], $object['recurrence_date']); } // set instance and 'savemode' according to recurrence-id else if (!empty($object['recurrence_date']) && is_a($object['recurrence_date'], 'DateTime')) { $object['_instance'] = self::recurrence_instance_identifier($object); $object['_savemode'] = $object['thisandfuture'] ? 'future' : 'current'; } else if (!empty($object['recurrence_id']) && !empty($object['_instance'])) { if (strlen($object['_instance']) > 4) { $object['recurrence_date'] = rcube_utils::anytodatetime($object['_instance'], $object['start']->getTimezone()); } else { $object['recurrence_date'] = clone $object['start']; } } } /** * Return a date() format string to render identifiers for recurrence instances * * @param array Hash array with event properties * @return string Format string */ public static function recurrence_id_format($event) { return $event['allday'] ? 'Ymd' : 'Ymd\THis'; } /** * Return the identifer for the given instance of a recurring event * * @param array Hash array with event properties * @param bool All-day flag from the main event * * @return mixed Format string or null if identifier cannot be generated */ public static function recurrence_instance_identifier($event, $allday = null) { $instance_date = $event['recurrence_date'] ?: $event['start']; if ($instance_date && is_a($instance_date, 'DateTime')) { // According to RFC5545 (3.8.4.4) RECURRENCE-ID format should // be date/date-time depending on the main event type, not the exception if ($allday === null) { $allday = $event['allday']; } return $instance_date->format($allday ? 'Ymd' : 'Ymd\THis'); } } /********* Attendee handling functions *********/ /** * Handler for attendee group expansion requests */ public function expand_attendee_group() { $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); $result = array('id' => $id, 'members' => array()); $maxnum = 500; // iterate over all autocomplete address books (we don't know the source of the group) foreach ((array)$this->rc->config->get('autocomplete_addressbooks', 'sql') as $abook_id) { if (($abook = $this->rc->get_address_book($abook_id)) && $abook->groups) { foreach ($abook->list_groups($data['name'], 1) as $group) { // this is the matching group to expand if (in_array($data['email'], (array)$group['email'])) { $abook->set_pagesize($maxnum); $abook->set_group($group['ID']); // get all members $res = $abook->list_records($this->rc->config->get('contactlist_fields')); // handle errors (e.g. sizelimit, timelimit) if ($abook->get_error()) { $result['error'] = $this->rc->gettext('expandattendeegrouperror', 'libcalendaring'); $res = false; } // check for maximum number of members (we don't wanna bloat the UI too much) else if ($res->count > $maxnum) { $result['error'] = $this->rc->gettext('expandattendeegroupsizelimit', 'libcalendaring'); $res = false; } while ($res && ($member = $res->iterate())) { $emails = (array)$abook->get_col_values('email', $member, true); if (!empty($emails) && ($email = array_shift($emails))) { $result['members'][] = array( 'email' => $email, 'name' => rcube_addressbook::compose_list_name($member), ); } } break 2; } } } } $this->rc->output->command('plugin.expand_attendee_callback', $result); } /** * Merge attendees of the old and new event version * with keeping current user and his delegatees status * * @param array &$new New object data * @param array $old Old object data * @param bool $status New status of the current user */ public function merge_attendees(&$new, $old, $status = null) { if (empty($status)) { $emails = $this->get_user_emails(); $delegates = array(); $attendees = array(); // keep attendee status of the current user foreach ((array) $new['attendees'] as $i => $attendee) { if (empty($attendee['email'])) { continue; } $attendees[] = $email = strtolower($attendee['email']); if (in_array($email, $emails)) { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i] = $_attendee; if ($_attendee['status'] == 'DELEGATED' && ($email = $_attendee['delegated-to'])) { $delegates[] = strtolower($email); } break; } } } } // make sure delegated attendee is not lost foreach ($delegates as $delegatee) { if (!in_array($delegatee, $attendees)) { foreach ((array) $old['attendees'] as $attendee) { if ($attendee['email'] && ($email = strtolower($attendee['email'])) && $email == $delegatee) { $new['attendees'][] = $attendee; break; } } } } } // We also make sure that status of any attendee // is not overriden by NEEDS-ACTION if it was already set // which could happen if you work with shared events foreach ((array) $new['attendees'] as $i => $attendee) { if ($attendee['email'] && $attendee['status'] == 'NEEDS-ACTION') { foreach ($old['attendees'] as $_attendee) { if ($attendee['email'] == $_attendee['email']) { $new['attendees'][$i]['status'] = $_attendee['status']; unset($new['attendees'][$i]['rsvp']); break; } } } } } /********* Static utility functions *********/ /** * Convert the internal structured data into a vcalendar rrule 2.0 string */ public static function to_rrule($recurrence, $allday = false) { if (is_string($recurrence)) return $recurrence; $rrule = ''; foreach ((array)$recurrence as $k => $val) { $k = strtoupper($k); switch ($k) { case 'UNTIL': // convert to UTC according to RFC 5545 if (is_a($val, 'DateTime')) { if (!$allday && !$val->_dateonly) { $until = clone $val; $until->setTimezone(new DateTimeZone('UTC')); $val = $until->format('Ymd\THis\Z'); } else { $val = $val->format('Ymd'); } } break; case 'RDATE': case 'EXDATE': foreach ((array)$val as $i => $ex) { if (is_a($ex, 'DateTime')) $val[$i] = $ex->format('Ymd\THis'); } $val = join(',', (array)$val); break; case 'EXCEPTIONS': continue 2; } if (strlen($val)) $rrule .= $k . '=' . $val . ';'; } return rtrim($rrule, ';'); } /** * Convert from fullcalendar date format to PHP date() format string */ public static function to_php_date_format($from) { // "dd.MM.yyyy HH:mm:ss" => "d.m.Y H:i:s" return strtr(strtr($from, array( 'YYYY' => 'Y', 'YY' => 'y', 'yyyy' => 'Y', 'yy' => 'y', 'MMMM' => 'F', 'MMM' => 'M', 'MM' => 'm', 'M' => 'n', 'dddd' => 'l', 'ddd' => 'D', 'dd' => 'd', 'd' => 'j', 'HH' => '**', 'hh' => '%%', 'H' => 'G', 'h' => 'g', 'mm' => 'i', 'ss' => 's', 'TT' => 'A', 'tt' => 'a', 'T' => 'A', 't' => 'a', 'u' => 'c', )), array( '**' => 'H', '%%' => 'h', )); } /** * Convert from PHP date() format to fullcalendar format string */ public static function from_php_date_format($from) { // "d.m.Y H:i:s" => "dd.MM.yyyy HH:mm:ss" return strtr($from, array( 'y' => 'yy', 'Y' => 'yyyy', 'M' => 'MMM', 'F' => 'MMMM', 'm' => 'MM', 'n' => 'M', 'j' => 'd', 'd' => 'dd', 'D' => 'ddd', 'l' => 'dddd', 'H' => 'HH', 'h' => 'hh', 'G' => 'H', 'g' => 'h', 'i' => 'mm', 's' => 'ss', 'A' => 'TT', 'a' => 'tt', 'c' => 'u', )); } } diff --git a/plugins/libkolab/lib/kolab_attachments_handler.php b/plugins/libkolab/lib/kolab_attachments_handler.php new file mode 100644 index 00000000..821aaaf9 --- /dev/null +++ b/plugins/libkolab/lib/kolab_attachments_handler.php @@ -0,0 +1,313 @@ + + * @author Aleksander Machniak + * + * Copyright (C) 2012-2018, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +class kolab_attachments_handler +{ + private $rc; + private $attachment; + + public function __construct() + { + $this->rc = rcmail::get_instance(); + } + + /** + * Displays attachment preview page + */ + public function attachment_page($attachment) + { + $this->attachment = $attachment; + + $this->rc->output->add_handler('plugin.attachmentframe', array($this, 'attachment_frame')); + $this->rc->output->add_handler('plugin.attachmentcontrols', array($this, 'attachment_header')); + $this->rc->output->set_env('filename', $attachment['name']); + $this->rc->output->set_env('mimetype', $attachment['mimetype']); + $this->rc->output->send('libkolab.attachment'); + } + + /** + * Handler for attachment uploads + */ + public function attachment_upload($session_key, $id_prefix = '') + { + // Upload progress update + if (!empty($_GET['_progress'])) { + $this->rc->upload_progress(); + } + + $recid = $id_prefix . rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); + $uploadid = rcube_utils::get_input_value('_uploadid', rcube_utils::INPUT_GPC); + + if (!is_array($_SESSION[$session_key]) || $_SESSION[$session_key]['id'] != $recid) { + $_SESSION[$session_key] = array(); + $_SESSION[$session_key]['id'] = $recid; + $_SESSION[$session_key]['attachments'] = array(); + } + + // clear all stored output properties (like scripts and env vars) + $this->rc->output->reset(); + + if (is_array($_FILES['_attachments']['tmp_name'])) { + foreach ($_FILES['_attachments']['tmp_name'] as $i => $filepath) { + // Process uploaded attachment if there is no error + $err = $_FILES['_attachments']['error'][$i]; + + if (!$err) { + $filename = $_FILES['_attachments']['name'][$i]; + $attachment = array( + 'path' => $filepath, + 'size' => $_FILES['_attachments']['size'][$i], + 'name' => $filename, + 'mimetype' => rcube_mime::file_content_type($filepath, $filename, $_FILES['_attachments']['type'][$i]), + 'group' => $recid, + ); + + $attachment = $this->rc->plugins->exec_hook('attachment_upload', $attachment); + } + + if (!$err && $attachment['status'] && !$attachment['abort']) { + $id = $attachment['id']; + + // store new attachment in session + unset($attachment['status'], $attachment['abort']); + $this->rc->session->append($session_key . '.attachments', $id, $attachment); + + if (($icon = $_SESSION[$session_key . '_deleteicon']) && is_file($icon)) { + $button = html::img(array( + 'src' => $icon, + 'alt' => $this->rc->gettext('delete') + )); + } + else if ($_SESSION[$session_key . '_textbuttons']) { + $button = rcube::Q($this->rc->gettext('delete')); + } + else { + $button = ''; + } + + $link_content = sprintf('%s(%s)', + rcube::Q($attachment['name']), $this->rc->show_bytes($attachment['size'])); + + $delete_link = html::a(array( + 'href' => "#delete", + 'class' => 'delete', + 'onclick' => sprintf("return %s.remove_from_attachment_list('rcmfile%s')", rcmail_output::JS_OBJECT_NAME, $id), + 'title' => $this->rc->gettext('delete'), + 'aria-label' => $this->rc->gettext('delete') . ' ' . $attachment['name'], + ), $button); + + $content_link = html::a(array( + 'href' => "#load", + 'class' => 'filename', + 'onclick' => 'return false', // sprintf("return %s.command('load-attachment','rcmfile%s', this, event)", rcmail_output::JS_OBJECT_NAME, $id), + ), $link_content); + + $content .= $_SESSION[$session_key . '_icon_pos'] == 'left' ? $delete_link.$content_link : $content_link.$delete_link; + + $this->rc->output->command('add2attachment_list', "rcmfile$id", array( + 'html' => $content, + 'name' => $attachment['name'], + 'mimetype' => $attachment['mimetype'], + 'classname' => 'no-menu ' . rcube_utils::file2class($attachment['mimetype'], $attachment['name']), + 'complete' => true + ), $uploadid); + } + else { // upload failed + if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { + $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( + 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); + } + else if ($attachment['error']) { + $msg = $attachment['error']; + } + else { + $msg = $this->rc->gettext('fileuploaderror'); + } + + $this->rc->output->command('display_message', $msg, 'error'); + $this->rc->output->command('remove_from_attachment_list', $uploadid); + } + } + } + else if ($_SERVER['REQUEST_METHOD'] == 'POST') { + // if filesize exceeds post_max_size then $_FILES array is empty, + // show filesizeerror instead of fileuploaderror + if ($maxsize = ini_get('post_max_size')) + $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( + 'size' => $this->rc->show_bytes(parse_bytes($maxsize))))); + else + $msg = $this->rc->gettext('fileuploaderror'); + + $this->rc->output->command('display_message', $msg, 'error'); + $this->rc->output->command('remove_from_attachment_list', $uploadid); + } + + $this->rc->output->send('iframe'); + } + + + /** + * Deliver an event/task attachment to the client + * (similar as in Roundcube core program/steps/mail/get.inc) + */ + public function attachment_get($attachment) + { + ob_end_clean(); + + if ($attachment && $attachment['body']) { + // allow post-processing of the attachment body + $part = new rcube_message_part; + $part->filename = $attachment['name']; + $part->size = $attachment['size']; + $part->mimetype = $attachment['mimetype']; + + $plugin = $this->rc->plugins->exec_hook('message_part_get', array( + 'body' => $attachment['body'], + 'mimetype' => strtolower($attachment['mimetype']), + 'download' => !empty($_GET['_download']), + 'part' => $part, + )); + + if ($plugin['abort']) + exit; + + $mimetype = $plugin['mimetype']; + list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); + + $browser = $this->rc->output->browser; + + // send download headers + if ($plugin['download']) { + header("Content-Type: application/octet-stream"); + if ($browser->ie) + header("Content-Type: application/force-download"); + } + else if ($ctype_primary == 'text') { + header("Content-Type: text/$ctype_secondary"); + } + else { + header("Content-Type: $mimetype"); + header("Content-Transfer-Encoding: binary"); + } + + // display page, @TODO: support text/plain (and maybe some other text formats) + if ($mimetype == 'text/html' && empty($_GET['_download'])) { + $OUTPUT = new rcmail_html_page(); + // @TODO: use washtml on $body + $OUTPUT->write($plugin['body']); + } + else { + // don't kill the connection if download takes more than 30 sec. + @set_time_limit(0); + + $filename = $attachment['name']; + $filename = preg_replace('[\r\n]', '', $filename); + + if ($browser->ie && $browser->ver < 7) + $filename = rawurlencode(abbreviate_string($filename, 55)); + else if ($browser->ie) + $filename = rawurlencode($filename); + else + $filename = addcslashes($filename, '"'); + + $disposition = !empty($_GET['_download']) ? 'attachment' : 'inline'; + header("Content-Disposition: $disposition; filename=\"$filename\""); + + echo $plugin['body']; + } + + exit; + } + + // if we arrive here, the requested part was not found + header('HTTP/1.1 404 Not Found'); + exit; + } + + /** + * Show "loading..." page in attachment iframe + */ + public function attachment_loading_page() + { + $url = str_replace('&_preload=1', '', $_SERVER['REQUEST_URI']); + $message = $this->rc->gettext('loadingdata'); + + header('Content-Type: text/html; charset=' . RCUBE_CHARSET); + print "\n\n" + . '' . "\n" + . '' . "\n" + . "\n\n$message\n\n"; + exit; + } + + /** + * Template object for attachment display frame + */ + public function attachment_frame($attrib = array()) + { + $mimetype = strtolower($this->attachment['mimetype']); + list($ctype_primary, $ctype_secondary) = explode('/', $mimetype); + + $attrib['src'] = './?' . str_replace('_frame=', ($ctype_primary == 'text' ? '_show=' : '_preload='), $_SERVER['QUERY_STRING']); + + $this->rc->output->add_gui_object('attachmentframe', $attrib['id']); + + return html::iframe($attrib); + } + + /** + * + */ + public function attachment_header($attrib = array()) + { + $rcmail = rcmail::get_instance(); + $dl_link = strtolower($attrib['downloadlink']) == 'true'; + $dl_url = $this->rc->url(array('_frame' => null, '_download' => 1) + $_GET); + $table = new html_table(array('cols' => $dl_link ? 3 : 2)); + + if (!empty($this->attachment['name'])) { + $table->add('title', rcube::Q($this->rc->gettext('filename'))); + $table->add('header', rcube::Q($this->attachment['name'])); + + if ($dl_link) { + $table->add('download-link', html::a($dl_url, rcube::Q($this->rc->gettext('download')))); + } + } + + if (!empty($this->attachment['mimetype'])) { + $table->add('title', rcube::Q($this->rc->gettext('type'))); + $table->add('header', rcube::Q($this->attachment['mimetype'])); + } + + if (!empty($this->attachment['size'])) { + $table->add('title', rcube::Q($this->rc->gettext('filesize'))); + $table->add('header', rcube::Q($this->rc->show_bytes($this->attachment['size']))); + } + + $this->rc->output->set_env('attachment_download_url', $dl_url); + + return $table->show($attrib); + } +} diff --git a/plugins/tasklist/skins/elastic/templates/attachment.html b/plugins/libkolab/skins/elastic/templates/attachment.html similarity index 83% rename from plugins/tasklist/skins/elastic/templates/attachment.html rename to plugins/libkolab/skins/elastic/templates/attachment.html index e81fabe4..eb4d6950 100644 --- a/plugins/tasklist/skins/elastic/templates/attachment.html +++ b/plugins/libkolab/skins/elastic/templates/attachment.html @@ -1,67 +1,86 @@

        + + diff --git a/plugins/libkolab/skins/larry/libkolab.css b/plugins/libkolab/skins/larry/libkolab.css index a9791007..3be1c926 100644 --- a/plugins/libkolab/skins/larry/libkolab.css +++ b/plugins/libkolab/skins/larry/libkolab.css @@ -1,87 +1,138 @@ ul.toolbarmenu li a.history span.history { background-image: url('data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAQAAAD8x0bcAAABXUlEQVQoFbXBvUsCYQAH4N/mdm5uN9UStzn1AYG0HC1Jg01tV/9A0NYQ5dRm4BI0VYRYUYOD3CSB2FaLXBJhIXYeXr7veVGd8qus0D6Whp4H+Duq1LnEBDX8jiqz3XpgPzqB3a0zSw3fUetavlux0hkjlc5cnPtut84EBlEJLr1WOoN5RKEiisTylmMHNjX0Pa17rd0TRAFqzOLN8Ma2FN4u+tpVx8Y4Xp1O+y7eaRXroUkFn7xWqYieXFwKfDjek6IxCYAqy6xJ8dBkjatALi4FDfQUN6XIxfEqPJt0bCmkMAuIAXMjx/nnBvPUAGtfip0p9ERmk45tFqDjTQix0TXrzFqg4t3cVhHBh8jECnQqVPAujAS0zqHXOshgQJhKx+ycUUcPx5j33YqFcXwRMo/a94HNMrMsd+58t1gaWsQPMSNlFq7Kvlu7LpaMFGYQwi9U6JjHEgzMQMU/eAHASL3dDo5S4wAAAABJRU5ErkJggg=='); background-position: 0px 2px; background-repeat: no-repeat; } .changelog-table .loading { color: #666; margin: 1em 0; padding: 1px 0 2px 24px; background: url('data:image/gif;base64,R0lGODlhEAAQAPQAAP///xB1v/j6/ESTzIu83xV4wDOJyNjo9K3P6CSAxH603G+s2OXv957G5Mnf8FOb0GCj1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAAFUCAgjmRpnqUwFGwhKoRgqq2YFMaRGjWA8AbZiIBbjQQ8AmmFUJEQhQGJhaKOrCksgEla+KIkYvC6SJKQOISoNSYdeIk1ayA8ExTyeR3F749CACH5BAkKAAAALAAAAAAQABAAAAVoICCKR9KMaCoaxeCoqEAkRX3AwMHWxQIIjJSAZWgUEgzBwCBAEQpMwIDwY1FHgwJCtOW2UDWYIDyqNVVkUbYr6CK+o2eUMKgWrqKhj0FrEM8jQQALPFA3MAc8CQSAMA5ZBjgqDQmHIyEAIfkECQoAAAAsAAAAABAAEAAABWAgII4j85Ao2hRIKgrEUBQJLaSHMe8zgQo6Q8sxS7RIhILhBkgumCTZsXkACBC+0cwF2GoLLoFXREDcDlkAojBICRaFLDCOQtQKjmsQSubtDFU/NXcDBHwkaw1cKQ8MiyEAIfkECQoAAAAsAAAAABAAEAAABVIgII5kaZ6AIJQCMRTFQKiDQx4GrBfGa4uCnAEhQuRgPwCBtwK+kCNFgjh6QlFYgGO7baJ2CxIioSDpwqNggWCGDVVGphly3BkOpXDrKfNm/4AhACH5BAkKAAAALAAAAAAQABAAAAVgICCOZGmeqEAMRTEQwskYbV0Yx7kYSIzQhtgoBxCKBDQCIOcoLBimRiFhSABYU5gIgW01pLUBYkRItAYAqrlhYiwKjiWAcDMWY8QjsCf4DewiBzQ2N1AmKlgvgCiMjSQhACH5BAkKAAAALAAAAAAQABAAAAVfICCOZGmeqEgUxUAIpkA0AMKyxkEiSZEIsJqhYAg+boUFSTAkiBiNHks3sg1ILAfBiS10gyqCg0UaFBCkwy3RYKiIYMAC+RAxiQgYsJdAjw5DN2gILzEEZgVcKYuMJiEAOwAAAAAAAAAAAA==') top left no-repeat; } .changelog-dialog .compare-button { margin: 4px 0; } .changelog-table tbody td { padding: 4px 7px; vertical-align: middle; } .changelog-table tbody tr:last-child td { border-bottom: 0; } .changelog-table tbody tr.undisclosed td.date, .changelog-table tbody tr.undisclosed td.user { font-style: italic; } .changelog-table .diff { width: 4em; padding: 2px; } .changelog-table .revision { width: 6em; } .changelog-table .date { width: 11em; } .changelog-table .user { width: auto; } .changelog-table .operation { width: 15%; } .changelog-table .actions { width: 50px; text-align: right; padding: 4px; } #mailmessagehistory .changelog-table .diff, #mailmessagehistory .changelog-table .actions { display: none; } #mailmessagehistory .changelog-table .operation { width: 25%; } fieldset.birthdays .proplist li { padding: 0; border: 0; } fieldset.categories input { height: auto !important; line-height: 16px !important; } #calendarcategories .minicolors-swatch { height: 14px; width: 14px; } a.button.input-group-text { line-height: 19px; vertical-align: middle; } + +.libkolab.attachmentwin #attachmenttoolbar { + position: relative; + top: -6px; + height: 40px; +} + +.libkolab.attachmentwin #attachmentcontainer { + position: absolute; + top: 0; + left: 232px; + right: 0; + bottom: 0; +} + +.libkolab.attachmentwin #attachmentframe { + width: 100%; + height: 100%; + border: 0; + background-color: #fff; + border-radius: 4px; +} + +.libkolab.attachmentwin #partheader { + position: absolute; + top: 0; + left: 0; + width: 220px; + bottom: 0; +} + +.libkolab.attachmentwin #partheader table { + table-layout: fixed; + overflow: hidden; +} + +.libkolab.attachmentwin #partheader table td { + color: #666; + padding: 4px 6px; + text-overflow: ellipsis; + overflow: hidden; +} + +.libkolab.attachmentwin #partheader table td.header { + font-weight: bold; +} + +.libkolab.attachmentwin #partheader table td.title { + width: 60px; + padding-right: 0; +} diff --git a/plugins/tasklist/skins/larry/templates/attachment.html b/plugins/libkolab/skins/larry/templates/attachment.html similarity index 69% rename from plugins/tasklist/skins/larry/templates/attachment.html rename to plugins/libkolab/skins/larry/templates/attachment.html index 68d4dd99..664d5f7c 100644 --- a/plugins/tasklist/skins/larry/templates/attachment.html +++ b/plugins/libkolab/skins/larry/templates/attachment.html @@ -1,65 +1,82 @@ <roundcube:object name="pagetitle" /> - +

        :

        diff --git a/plugins/tasklist/skins/elastic/templates/taskedit.html b/plugins/tasklist/skins/elastic/templates/taskedit.html index f262b003..d8ee9006 100644 --- a/plugins/tasklist/skins/elastic/templates/taskedit.html +++ b/plugins/tasklist/skins/elastic/templates/taskedit.html @@ -1,127 +1,127 @@ diff --git a/plugins/tasklist/skins/larry/tasklist.css b/plugins/tasklist/skins/larry/tasklist.css index 0241dc34..728c6bac 100644 --- a/plugins/tasklist/skins/larry/tasklist.css +++ b/plugins/tasklist/skins/larry/tasklist.css @@ -1,1438 +1,1386 @@ /** * Roundcube Taklist plugin styles for skin "Larry" * * Copyright (C) 2012, Kolab Systems AG * Screendesign by FLINT / Bรผro fรผr Gestaltung, bueroflint.com * * The contents are subject to the Creative Commons Attribution-ShareAlike * License. It is allowed to copy, distribute, transmit and to adapt the work * by keeping credits to the original autors in the README file. * See http://creativecommons.org/licenses/by-sa/3.0/ for details. */ #taskbar a.button-tasklist span.button-inner { background-image: url(images/buttons.png); background-position: 0 0; } #taskbar a.button-tasklist:hover span.button-inner, #taskbar a.button-tasklist.button-selected span.button-inner { background-position: 0 -26px; } ul.toolbarmenu li span.icon.taskadd, #attachmentmenu li a.tasklistlink span.icon.taskadd { background-image: url(images/buttons.png); background-position: -4px -90px; } #taskstoolbar a.button.export { background-image: url(images/buttons.png); background-position: center -179px; min-width: 50px; max-width: 70px; } #taskstoolbar a.button.import { background-image: url(images/buttons.png); background-position: center -139px; } #taskedit.uidialog, .tasklistview div.uidialog { display: none; } .tasklistview #mainscreen { min-width: 1000px !important; min-height: 520px !important; } .tasklistview #header { min-width: 1020px !important; } #sidebar { position: absolute; top: 0; left: 0; bottom: 0; width: 240px; } .tasklistview #searchmenulink { width: 15px; } #tasklistsbox { position: absolute; top: 42px; left: 0; width: 100%; bottom: 0px; } #tasklistsbox .boxtitle a.iconbutton.search { position: absolute; top: 8px; right: 8px; width: 16px; cursor: pointer; background-position: -2px -317px; } #tasklistsbox .listsearchbox { display: none; } #tasklistsbox .listsearchbox.expanded { display: block; } #tasklistsbox .scroller { top: 34px; } #tasklistsbox .listsearchbox.expanded + .scroller { top: 68px; } #taskselector { margin: -1px 40px 0 0; padding: 0; } #taskselector li { display: inline-block; position: relative; font-size: 90%; padding-right: 0.3em; } #taskselector li a { display: inline-block; color: #004458; min-width: 3.5em; padding: 0.2em 0.6em 0.2em 0.6em; text-align: center; text-decoration: none; border: 1px solid #eee; border-color: transparent; } .webkit #taskselector li a { padding-bottom: 0.25em; } #taskselector li:first-child { border-top: 0; border-radius: 4px 4px 0 0; } #taskselector li:last-child { border-bottom: 0; border-radius: 0 0 4px 4px; } #taskselector li.overdue a { color: #b72a2a; font-weight: bold; } #taskselector li.inactive a { color: #97b3bf; } #taskselector li.selected a { color: #fff; background: #005d76; border-radius: 10px; text-shadow: none; } #taskselector li .count { display: none; position: absolute; top: -18px; right: 5px; min-width: 1.8em; padding: 2px 4px; background: #004558; color: #fff; border-radius: 3px; text-align: center; font-weight: bold; font-size: 80%; text-shadow: none; } #taskselector li .count:after { content: ""; position: absolute; bottom: -5px; left: 50%; margin-left: -5px; border-style: solid; border-width: 5px 5px 0; border-color: #004558 transparent; /* reduce the damage in FF3.0 */ display: block; width: 0; } #taskselector li.overdue .count { background: #ff3800; } #taskselector li.overdue .count:after { border-color: #ff3800 transparent; } #tasklistsbox .treelist li { margin: 0; display: block; position: relative; } #tasklistsbox .treelist li div.tasklist { margin: 0; height: 20px; padding: 6px 8px 2px 6px; position: relative; white-space: nowrap; } #tasklistsbox .treelist li.virtual > div.tasklist { height: 14px; } #tasklistsbox .treelist ul li > div.tasklist { margin-left: 16px; } #tasklistsbox .treelist ul ul li > div.tasklist { margin-left: 32px; } #tasklistsbox .treelist ul ul ul li > div.tasklist { margin-left: 48px; } #tasklistsbox .treelist li label { display: block; } #tasklistsbox .treelist li a.listname { display: block; position: absolute; top: 7px; left: 38px; right: 40px; cursor: default; padding: 0px 30px 2px 2px; color: #004458; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: url(images/sprites.png) right 20px no-repeat; } .quickview-active #tasklistsbox .treelist li input, .quickview-active #tasklistsbox .treelist li a.listname { opacity: 0.35; } .quickview-active #tasklistsbox .treelist div.focusview a.listname { opacity: 1.0; } #tasklistsbox .treelist div span.actions { display: inline-block; position: absolute; top: 2px; right: 2px; padding: 5px 20px 0 6px; min-width: 40px; height: 19px; text-align: right; } #tasklistsbox .treelist div:hover span.actions { top: 1px; right: 1px; border: 1px solid #ababab; border-radius: 4px; background: #f1f1f1; } #tasklistsbox .treelist div a.remove, #tasklistsbox .treelist div a.quickview, #tasklistsbox .treelist div a.subscribed { display: inline-block; width: 16px; height: 16px; padding: 0; margin-right: 4px; background: url(images/sprites.png) -200px 0 no-repeat; overflow: hidden; text-indent: -5000px; cursor: pointer; } #tasklistsbox .treelist div a.subscribed { position: absolute; top: 5px; right: 4px; margin: 0; } #tasklistsbox .treelist div a.subscribed:focus, #tasklistsbox .treelist div:hover a.subscribed { background-position: -2px -215px; } #tasklistsbox .treelist div.subscribed a.subscribed { background-position: -20px -215px; } #tasklistsbox .treelist div a.quickview:focus, #tasklistsbox .treelist div:hover a.quickview { background-position: -20px -101px; background-color: transparent !important; } #tasklistsbox .treelist div a.remove:focus, #tasklistsbox .treelist div:hover a.remove { background-position: -2px -371px; background-color: transparent !important; } #tasklistsbox .treelist div.focusview a.quickview { background-position: -2px -101px; } #tasklistsbox .searchresults .treelist div a.remove, #tasklistsbox .searchresults .treelist div a.quickview { display: none; } #tasklistsbox .treelist div a.remove:focus, #tasklistsbox .treelist div a.quickview:focus, #tasklistsbox .treelist div a.subscribed:focus { border-radius: 3px; outline: 2px solid rgba(30,150,192, 0.5); } #tasklistsbox .treelist li.selected > div > a.listname { font-weight: bold; } #tasklistsbox .treelist .readonly > a.listname { background-position: right -142px; } #tasklistsbox .treelist .user > a.listname { background-position: right -160px; } #tasklistsbox .treelist .virtual > a.listname { color: #aaa; top: 4px; left: 20px; right: 5px; } #tasklistsbox .treelist.flat li span.calname { left: 24px; right: 22px; } #tasklistsbox .treelist li input { position: absolute; top: 5px; left: 18px; } #tasklistsbox .treelist li .treetoggle { top: 8px; } #tasklistsbox .treelist li.virtual > .treetoggle { top: 6px; } #tasklistsbox .searchresults { background: #b0ccd7; margin-top: 8px; } #tasklistsbox .searchresults .boxtitle { background: none; padding: 2px 8px 2px 8px; } #tasklistsbox .searchresults .listing li { background-color: #c7e3ef; } #mainview-right { position: absolute; top: 0; left: 256px; right: 0; bottom: 0; } #taskstoolbar { position: absolute; top: -6px; left: 0; width: 100%; height: 40px; white-space: nowrap; } #taskstoolbar a.button.newtask { background-image: url(images/buttons.png); background-position: center -53px; } #quickaddbox { position: absolute; top: 2px; left: 0; width: 60%; height: 32px; white-space: nowrap; } #quickaddinput { width: 85%; margin: 0; padding: 3px 8px; height: 18px; background: #f1f1f1; background: rgba(255, 255, 255, 0.7); border-color: #a3a3a3; font-weight: bold; } #quickaddbox .button { margin-left: 5px; padding: 4px 10px; font-weight: bold; } #tasksview { position: absolute; top: 42px; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.25); overflow: visible; } .quickview-active #tasksview { background-image: url('images/focusview.png'); background-position: center; background-repeat: no-repeat; } #message.statusbar { border-top: 1px solid #c3c3c3; } #tasksview .scroller { position: absolute; left: 0; top: 35px; width: 100%; bottom: 0; overflow: auto; } #tasksview .buttonbar { color: #777; background: #eaeaea; border-bottom: 1px solid #ccc; position: relative; line-height: 13px; height: 20px; } #tasksview .buttonbar .buttonbar-right { position: absolute; top: 6px; right: 8px; } .buttonbar-right .listmenu { display: inline-block; cursor: pointer; } .buttonbar-right a.iconbutton { padding: 0; background-image: url(images/sprites.png); background-position: 0 -238px; } .buttonbar-right a.iconbutton.sorting { background-position: -18px -347px; } #thelist { padding: 0; margin: 1em; list-style: none; } #listmessagebox { display: none; font-size: 14px; color: #666; margin: 1.5em; text-align:center; } .taskitem { position: relative; display: block; margin-bottom: 3px; } .taskitem.dragging { opacity: 0.5; } .taskitem .childtasks { position: relative; padding: 0; margin: 3px 0 0 20px; list-style: none; } .taskitem .childtoggle { display: none; position: absolute; top: 4px; left: -5px; padding: 2px; font-size: 10px; color: #727272; cursor: pointer; width: 14px; height: 14px; background: url(images/sprites.png) -2px -80px no-repeat; text-indent: -1000px; overflow: hidden; } .taskitem .childtoggle.collapsed { background-position: -18px -81px; } .taskhead { position: relative; margin-left: 14px; padding: 4px 5px 3px 5px; border: 1px solid #fff; border-radius: 5px; background: #fff; -webkit-box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.4); -moz-box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.4); box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.4); padding-right: 26em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; outline: none; } .taskhead:focus, .taskhead.droptarget { border-color: #4787b1; box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); } .taskhead .complete { margin: -1px 1em 0 0; } .taskhead .title { font-size: 12px; } .taskhead .flagged, .taskshow.status-flagged h2:after { display: inline-block; width: 16px; height: 16px; background: url(images/sprites.png) 1000px -3px no-repeat; margin: -3px 1em 0 0; vertical-align: middle; cursor: pointer; } .taskhead .flagged:focus, .taskhead:hover .flagged { background-position: -2px -3px; } .taskhead.flagged .flagged, .taskshow.status-flagged h2:after { background-position: -2px -23px; } .taskhead .tags { display: block; position: absolute; top: 3px; right: 10em; max-width: 14em; height: 16px; overflow: hidden; padding-top: 1px; text-align: right; } .tag-draghelper .tag, .taskhead .tags .tag { font-size: 85%; background: #d9ecf4; border: 1px solid #c2dae5; border-radius: 4px; padding: 1px 7px; margin-right: 3px; } .tag-draghelper li.tag { list-style: none; font-size: 100%; } .taskhead .date { position: absolute; top: 4px; right: 30px; text-align: right; cursor: pointer; } .taskhead.nodate .date { color: #ddd; } .taskhead.overdue .date { color: #d00; } .taskhead.nodate:hover .date { color: #999; } .taskhead .date input { padding: 1px 2px; border: 1px solid #ddd; -webkit-box-shadow: none; -moz-box-shadow: none; box-shadow: none; outline: none; text-align: right; width: 6em; font-size: 11px; } .taskhead .actions { display: block; position: absolute; top: 3px; right: 6px; width: 18px; height: 18px; background: url(images/sprites.png) 1000px -80px no-repeat; text-indent: -5000px; overflow: hidden; cursor: pointer; } .taskhead .actions:focus, .taskhead:hover .actions { background-position: 0 -80px; } .taskhead.complete { opacity: 0.6; } .taskhead.complete .title { text-decoration: line-through; } .taskhead .progressbar { position: absolute; bottom: 1px; left: 6px; right: 6px; height: 2px; } .taskhead.complete .progressbar { display: none; } .taskhead .progressvalue { height: 1px; background: rgba(1, 124, 180, 0.2); border-top: 1px solid #219de6; } ul.toolbarmenu li span.add, ul.toolbarmenu li span.expand, ul.toolbarmenu li span.collapse, ul.toolbarmenu li span.history, ul.toolbarmenu.iconized .selected span.icon { background-image: url(images/sprites.png); } ul.toolbarmenu li span.add { background-position: 0 -302px; } ul.toolbarmenu li span.expand { background-position: 0 -258px; } ul.toolbarmenu li span.collapse { background-position: 0 -280px; } ul.toolbarmenu li span.delete { background-position: 0 -1508px; } ul.toolbarmenu li span.history { background-position: 0 -408px; } ul.toolbarmenu.iconized .selected span.icon { background-position: 0 -324px; } ul.toolbarmenu .sortcol.by-auto a { font-style: italic; } .taskitem-draghelper { /* width: 32px; height: 26px; */ background: #444; border: 1px solid #555; border-radius: 4px; box-shadow: 0 2px 6px 0 #333; -moz-box-shadow: 0 2px 6px 0 #333; -webkit-box-shadow: 0 2px 6px 0 #333; -o-box-shadow: 0 2px 6px 0 #333; z-index: 5000; padding: 2px 10px; font-size: 20px; color: #ccc; opacity: 0.92; filter: alpha(opacity=90); text-shadow: 0px 1px 1px #333; } #rootdroppable { display: none; position: absolute; top: 2px; left: 1em; right: 1em; height: 5px; background: #ddd; border-radius: 3px; } #rootdroppable.droptarget { background: #4787b1; box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); } /*** task edit form ***/ #taskedit, #taskshow, #taskdiff { display: none; } #taskedit { position: relative; top: -1.5em; padding: 0.5em 0.1em; margin: 0 -0.2em; } .taskshow h2 { margin-top: -0.5em; } #taskdiff h2 { font-size: 18px; margin: -0.3em 0 0.4em 0; } .taskshow.status-completed h2 { text-decoration: line-through; } .taskshow.status-flagged h2:after { content: " "; position: relative; margin-left: 0.6em; top: 1px; cursor: default; } .taskshow label { color: #999; } .taskshow.status-cancelled { background: url(images/badge_cancelled.png) top right no-repeat; } .task-parent-title { position: relative; top: -0.6em; } a.morelink { font-size: 90%; color: #0069a6; text-decoration: none; outline: none; } a.morelink:hover { text-decoration: underline; } #taskedit .ui-tabs-panel { min-height: 24em; } #taskeditform input.text, #taskeditform textarea { width: 97%; } #taskeditform .buttons { margin: 0.5em 0; } #taskedit .border-after { padding-bottom: 0.8em; margin-bottom: 0.8em; border-bottom: 2px solid #fafafa; } #taskedit .edit-attendees-table { width: 100%; margin-top: 0.5em; } #taskedit .edit-attendees-table tbody td { padding: 5px 7px 6px; } #taskedit .edit-attendees-table tbody tr:last-child td { border-bottom: 0; } #taskedit .edit-attendees-table th.role, #taskedit .edit-attendees-table td.role { width: 9em; } #taskedit .edit-attendees-table th.availability, #taskedit .edit-attendees-table td.availability, #taskedit .edit-attendees-table th.confirmstate, #taskedit .edit-attendees-table td.confirmstate { width: 6em; } #taskedit .edit-attendees-table th.options, #taskedit .edit-attendees-table td.options { width: 24px; padding: 2px 4px; text-align: right; } #taskedit .edit-attendees-table th.invite, #taskedit .edit-attendees-table td.invite { width: 50px; padding: 2px; } #taskedit .edit-attendees-table th.invite 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; } #taskedit .edit-attendees-table th.name, #taskedit .edit-attendees-table td.name { width: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #taskedit .edit-attendees-table td.name .attendee-name { display: block; position: relative; overflow: hidden; text-overflow: ellipsis; padding: 5px 7px 6px 4px; margin: -5px -7px -6px; } #taskedit .edit-attendees-table td.name select { width: 100%; } #taskedit .edit-attendees-table a.deletelink { display: inline-block; width: 17px; height: 17px; padding: 0; overflow: hidden; text-indent: 1000px; } #taskedit .edit-attendees-table a.expandlink { position: absolute; top: 4px; right: 6px; width: 16px; height: 16px; } #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; } #taskedit-attachments ul li { display: block; color: #333; font-weight: bold; padding: 2px 4px 2px 30px; text-shadow: 0px 1px 1px #fff; text-decoration: none; white-space: nowrap; line-height: 20px; } #taskedit-attachments ul li a.filename { padding: 0; } #taskedit-attachments-form { margin-top: 1em; padding-top: 0.8em; border-top: 2px solid #fafafa; } div.form-section { position: relative; margin-top: 0.2em; margin-bottom: 0.5em; } .form-section label { display: inline-block; min-width: 7em; padding-right: 0.5em; margin-bottom: 0.3em; } .tasklistview div.form-section span.task-text + label { margin-left: 2em; } label.block { display: block; margin-bottom: 0.3em; } .task-description { margin-bottom: 1em; } .taskshow .task-text-old, .taskshow .task-text-new, .taskshow .task-text-diff { padding: 2px; } .taskshow .task-text-diff del, .taskshow .task-text-diff ins { text-decoration: none; color: inherit; } .taskshow .task-text-old, .taskshow .task-text-diff del { background-color: #fdd; /* text-decoration: line-through; */ } .taskshow .task-text-new, .taskshow .task-text-diff ins { background-color: #dfd; } .tasklistview .taskshow label span.index { vertical-align: inherit; margin-left: 0.6em; } #taskedit-completeness-slider { display: inline-block; margin-left: 2em; width: 30em; height: 0.8em; border: 1px solid #ccc; } #taskedit-tagline { width: 97%; } #taskedit .droptarget { background-image: url(../../../../skins/larry/images/filedrop.png) !important; background-position: center bottom !important; background-repeat: no-repeat !important; } #taskedit .droptarget.hover, #taskedit .droptarget.active { border-color: #019bc6; box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -moz-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -webkit-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -o-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); } #taskedit .droptarget.hover { background-color: #d9ecf4; box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); } #task-links { margin-top: 0; margin-bottom: 0.2em; } #task-links label { vertical-align: top; margin-top: 0.3em; } #task-links .attachmentslist { display: inline-block; } #task-links .attachmentslist li { display: inline-block; margin-right: 1em; } #taskedit-links .attachmentslist li.message.eml, #task-links .attachmentslist li.message.eml { background-image: url(images/sprites.png); background-position: -2px -388px; } #taskedit-links .attachmentslist li.message a.messagelink, #task-links .attachmentslist li.message a.messagelink { padding: 0 0 0 24px; } #taskedit-links .attachmentslist li.deleted a.messagelink, #taskedit-links .attachmentslist li.deleted a.messagelink:hover { text-decoration: line-through; } #taskedit-links label { float: left; margin-top: 0.3em; } #taskedit-links .task-text { margin-left: 8em; min-height: 22px; } #taskedit-links .attachmentslist li a.delete { top: 0; background-position: -6px -378px; } #task-attachments .attachmentslist li { float: left; margin-right: 1em; } #task-attachments .attachmentslist li a { 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; } .tasklistview .uidialog .propform fieldset.ui-tabs-panel { min-height: 290px; } .tasklistview .uidialog .propform #taskedit-tasklistname { 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 { padding: 2px; } div.tasklist-invitebox td.ititle { font-weight: bold; padding-right: 0.5em; } div.tasklist-invitebox td.label { color: #666; padding-right: 1em; } #task-rsvp .rsvp-buttons, #task-rsvp .itip-reply-controls, div.tasklist-invitebox .itip-buttons div { margin-top: 0.5em; } #task-rsvp .itip-reply-controls a, #task-rsvp .itip-reply-controls label { color: #333; } #task-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; } #task-partstat .changersvp, .tasklistview .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; } #task-partstat .changersvp.declined, div.tasklist-invitebox .rsvp-status.declined, .tasklistview .edit-attendees-table td.confirmstate span.declined { background-position: 2px -40px; } #task-partstat .changersvp.tentative, div.tasklist-invitebox .rsvp-status.tentative, .tasklistview .edit-attendees-table td.confirmstate span.tentative { background-position: 2px -60px; } #task-partstat .changersvp.delegated, div.tasklist-invitebox .rsvp-status.delegated, .tasklistview .edit-attendees-table td.confirmstate span.delegated { background-position: 2px -180px; } #task-partstat .changersvp.needs-action, div.tasklist-invitebox .rsvp-status.needs-action, .tasklistview .edit-attendees-table td.confirmstate span.needs-action { background-position: 2px 0; } #task-partstat .changersvp.in-process, div.tasklist-invitebox .rsvp-status.in-process, .tasklistview .edit-attendees-table td.confirmstate span.in-process { background-position: 2px -200px; } #task-partstat .changersvp.accepted, div.tasklist-invitebox .rsvp-status.accepted, .tasklistview .edit-attendees-table td.confirmstate span.accepted { background-position: 2px -220px; } div.messagetasklinks { position: relative; margin: 8px 8px; padding: 4px 8px 4px 30px; border: 1px solid #dfdfdf; background: #fafafa; border-radius: 4px; } div.messagetasklinks::before { content: " "; position: absolute; top: 4px; left: 8px; width: 18px; height: 18px; background: url(images/buttons.png) -6px -115px no-repeat; } div.messagetasklinks ul.tasklist { margin: 0; padding: 0; list-style: none; } div.messagetasklinks .messagetaskref { display: block; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } div.messagetasklinks a.messagetasklink { position: relative; display: inline-block; color: #333; font-weight: bold; padding: 3px 0 2px 2px; text-shadow: 0px 1px 1px #fff; text-decoration: none; white-space: nowrap; vertical-align: middle; } div.messagetasklinks .messagetaskref.complete a.messagetasklink { text-decoration: line-through; text-shadow: none; color: #666; } div.messagetasklinks .messagetaskref input.complete { vertical-align: middle; } - -.tasklist.attachmentwin #attachmenttoolbar { - position: relative; - top: -6px; - height: 40px; -} - -.tasklist.attachmentwin #attachmentcontainer { - position: absolute; - top: 0; - left: 232px; - right: 0; - bottom: 0; -} - -.tasklist.attachmentwin #attachmentframe { - width: 100%; - height: 100%; - border: 0; - background-color: #fff; - border-radius: 4px; -} - -.tasklist.attachmentwin #partheader { - position: absolute; - top: 0; - left: 0; - width: 220px; - bottom: 0; -} - -.tasklist.attachmentwin #partheader table { - table-layout: fixed; - overflow: hidden; -} - -.tasklist.attachmentwin #partheader table td { - color: #666; - padding: 4px 6px; - text-overflow: ellipsis; - overflow: hidden; -} - -.tasklist.attachmentwin #partheader table td.header { - font-weight: bold; -} - -.tasklist.attachmentwin #partheader table td.title { - width: 60px; - padding-right: 0; -} - .form-section label + div { line-height: 2; vertical-align: top; } .form-section label + div { display: inline-block; } diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index ad0ec875..41d93948 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -1,2548 +1,2546 @@ * * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist extends rcube_plugin { const FILTER_MASK_TODAY = 1; const FILTER_MASK_TOMORROW = 2; const FILTER_MASK_WEEK = 4; const FILTER_MASK_LATER = 8; const FILTER_MASK_NODATE = 16; const FILTER_MASK_OVERDUE = 32; const FILTER_MASK_FLAGGED = 64; const FILTER_MASK_COMPLETE = 128; const FILTER_MASK_ASSIGNED = 256; const FILTER_MASK_MYTASKS = 512; const SESSION_KEY = 'tasklist_temp'; public static $filter_masks = array( 'today' => self::FILTER_MASK_TODAY, 'tomorrow' => self::FILTER_MASK_TOMORROW, 'week' => self::FILTER_MASK_WEEK, 'later' => self::FILTER_MASK_LATER, 'nodate' => self::FILTER_MASK_NODATE, 'overdue' => self::FILTER_MASK_OVERDUE, 'flagged' => self::FILTER_MASK_FLAGGED, 'complete' => self::FILTER_MASK_COMPLETE, 'assigned' => self::FILTER_MASK_ASSIGNED, 'mytasks' => self::FILTER_MASK_MYTASKS, ); public $task = '?(?!login|logout).*'; public $allowed_prefs = array('tasklist_sort_col','tasklist_sort_order'); public $rc; public $lib; public $timezone; public $ui; public $home; // declare public to be used in other classes // These are handled by __get() // public $driver; // public $itip; // public $ical; private $collapsed_tasks = array(); private $message_tasks = array(); /** * Plugin initialization. */ function init() { $this->require_plugin('libcalendaring'); $this->require_plugin('libkolab'); $this->require_plugin('jqueryui'); $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->register_task('tasks', 'tasklist'); // load plugin configuration $this->load_config(); $this->timezone = $this->lib->timezone; // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); $this->add_hook('user_delete', array($this, 'user_delete')); } /** * Startup hook */ public function startup($args) { // the tasks module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('tasklist_disabled', false) || !$this->rc->config->get('tasklist_enabled', true)) return; // load localizations $this->add_texts('localization/', $args['task'] == 'tasks' && (!$args['action'] || $args['action'] == 'print')); $this->rc->load_language($_SESSION['language'], array('tasks.tasks' => $this->gettext('navtitle'))); // add label for task title if ($args['task'] == 'tasks' && $args['action'] != 'save-pref') { $this->load_driver(); // register calendar actions $this->register_action('index', array($this, 'tasklist_view')); $this->register_action('task', array($this, 'task_action')); $this->register_action('tasklist', array($this, 'tasklist_action')); $this->register_action('counts', array($this, 'fetch_counts')); $this->register_action('fetch', array($this, 'fetch_tasks')); $this->register_action('print', array($this, 'print_tasks')); $this->register_action('inlineui', array($this, 'get_inline_ui')); $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('import', array($this, 'import_tasks')); $this->register_action('export', array($this, 'export_tasks')); $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->register_action('itip-delegate', array($this, 'mail_itip_delegate')); $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') { if ($args['action'] == 'show' || $args['action'] == 'preview') { if ($this->rc->config->get('tasklist_mail_embed', true)) { $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 if ($this->api->output->type == 'html') { $this->api->add_content(html::tag('li', null, $this->api->output->button(array( 'command' => 'tasklist-create-from-mail', 'label' => 'tasklist.createfrommail', 'type' => 'link', 'classact' => 'icon taskaddlink active', 'class' => 'icon taskaddlink', 'innerclass' => 'icon taskadd', ))), 'messagemenu'); $this->api->output->add_label('tasklist.createfrommail'); } } if (!$this->rc->output->ajax_call && !$this->rc->output->env['framed']) { $this->load_ui(); $this->ui->init(); } // add hooks for alarms handling $this->add_hook('pending_alarms', array($this, 'pending_alarms')); $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms')); } /** * */ private function load_ui() { if (!$this->ui) { require_once($this->home . '/tasklist_ui.php'); $this->ui = new tasklist_ui($this); } } /** * Helper method to load the backend driver according to local config */ private function load_driver() { if (is_object($this->driver)) { return; } $driver_name = $this->rc->config->get('tasklist_driver', 'database'); $driver_class = 'tasklist_' . $driver_name . '_driver'; require_once($this->home . '/drivers/tasklist_driver.php'); require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); $this->driver = new $driver_class($this); $this->rc->output->set_env('tasklist_driver', $driver_name); } /** * Dispatcher for task-related actions initiated by the client */ public function task_action() { $filter = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true); $oldrec = $rec; $success = $refresh = $got_msg = false; // force notify if hidden + active $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($itip_send_option === 1 && empty($rec['_reportpartstat'])) $rec['_notify'] = 1; switch ($action) { case 'new': $oldrec = null; $rec = $this->prepare_task($rec); $rec['uid'] = $this->generate_uid(); $temp_id = $rec['tempid']; if ($success = $this->driver->create_task($rec)) { $refresh = $this->driver->get_task($rec); if ($temp_id) $refresh['tempid'] = $temp_id; $this->cleanup_task($rec); } break; case 'complete': $complete = intval(rcube_utils::get_input_value('complete', rcube_utils::INPUT_POST)); if (!($rec = $this->driver->get_task($rec))) { break; } $oldrec = $rec; $rec['status'] = $complete ? 'COMPLETED' : ($rec['complete'] > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION'); // sent itip notifications if enabled (no user interaction here) if (($itip_send_option & 1)) { if ($this->is_attendee($rec)) { $rec['_reportpartstat'] = $rec['status']; } else if ($this->is_organizer($rec)) { $rec['_notify'] = 1; } } case 'edit': $oldrec = $this->driver->get_task($rec); $rec = $this->prepare_task($rec); $clone = $this->handle_recurrence($rec, $this->driver->get_task($rec)); if ($success = $this->driver->edit_task($rec)) { $new_task = $this->driver->get_task($rec); $new_task['tempid'] = $rec['id']; $refresh[] = $new_task; $this->cleanup_task($rec); // add clone from recurring task if ($clone && $this->driver->create_task($clone)) { $new_clone = $this->driver->get_task($clone); $new_clone['tempid'] = $clone['id']; $refresh[] = $new_clone; $this->driver->clear_alarms($rec['id']); } // move all childs if list assignment was changed if (!empty($rec['_fromlist']) && !empty($rec['list']) && $rec['_fromlist'] != $rec['list']) { foreach ($this->driver->get_childs(array('id' => $rec['id'], 'list' => $rec['_fromlist']), true) as $cid) { $child = array('id' => $cid, 'list' => $rec['list'], '_fromlist' => $rec['_fromlist']); if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'move': foreach ((array)$rec['id'] as $id) { $r = $rec; $r['id'] = $id; if ($this->driver->move_task($r)) { $new_task = $this->driver->get_task($r); $new_task['tempid'] = $id; $refresh[] = $new_task; $success = true; // move all childs, too foreach ($this->driver->get_childs(array('id' => $id, 'list' => $rec['_fromlist']), true) as $cid) { $child = $rec; $child['id'] = $cid; if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'delete': $mode = intval(rcube_utils::get_input_value('mode', rcube_utils::INPUT_POST)); $oldrec = $this->driver->get_task($rec); if ($success = $this->driver->delete_task($rec, false)) { // delete/modify all childs foreach ($this->driver->get_childs($rec, $mode) as $cid) { $child = array('id' => $cid, 'list' => $rec['list']); if ($mode == 1) { // delete all childs if ($this->driver->delete_task($child, false)) { if ($this->driver->undelete) $_SESSION['tasklist_undelete'][$rec['id']][] = $cid; } else $success = false; } else { $child['parent_id'] = strval($oldrec['parent_id']); $this->driver->edit_task($child); } } // update parent task to adjust list of children if (!empty($oldrec['parent_id'])) { $parent = array('id' => $oldrec['parent_id'], 'list' => $rec['list']); if ($parent = $this->driver->get_task()) { $refresh[] = $parent; } } } if (!$success) $this->rc->output->command('plugin.reload_data'); break; case 'undelete': if ($success = $this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); foreach ((array)$_SESSION['tasklist_undelete'][$rec['id']] as $cid) { if ($this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); } } } break; case 'collapse': foreach (explode(',', $rec['id']) as $rec_id) { if (intval(rcube_utils::get_input_value('collapsed', rcube_utils::INPUT_GPC))) { $this->collapsed_tasks[] = $rec_id; } else { $i = array_search($rec_id, $this->collapsed_tasks); if ($i !== false) unset($this->collapsed_tasks[$i]); } } $this->rc->user->save_prefs(array('tasklist_collapsed_tasks' => join(',', array_unique($this->collapsed_tasks)))); return; // avoid further actions case 'rsvp': $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_GPC); $noreply = intval(rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC)) || $status == 'needs-action'; $task = $this->driver->get_task($rec); $task['attendees'] = $rec['attendees']; $task['_type'] = 'task'; // send invitation to delegatee + add it as attendee if ($status == 'delegated' && $rec['to']) { $itip = $this->load_itip(); if ($itip->delegate_to($task, $rec['to'], (bool)$rec['rsvp'])) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); $refresh[] = $task; $noreply = false; } } $rec = $task; if ($success = $this->driver->edit_task($rec)) { if (!$noreply) { // let the reply clause further down send the iTip message $rec['_reportpartstat'] = $status; } } break; case 'changelog': $data = $this->driver->get_task_changelog($rec); if (is_array($data) && !empty($data)) { $lib = $this->lib; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($data, function(&$change) use ($lib, $dtformat) { if ($change['date']) { $dt = $lib->adjust_timezone($change['date']); if ($dt instanceof DateTime) { $change['date'] = $this->rc->format_date($dt, $dtformat, false); } } }); $this->rc->output->command('plugin.task_render_changelog', $data); } else { $this->rc->output->command('plugin.task_render_changelog', false); } $got_msg = true; break; case 'diff': $data = $this->driver->get_task_diff($rec, $rec['rev1'], $rec['rev2']); if (is_array($data)) { // convert some properties, similar to self::_client_event() $lib = $this->lib; $date_format = $this->rc->config->get('date_format', 'Y-m-d'); $time_format = $this->rc->config->get('time_format', 'H:i'); array_walk($data['changes'], function(&$change, $i) use ($lib, $date_format, $time_format) { // convert date cols if (in_array($change['property'], array('date','start','created','changed'))) { if (!empty($change['old'])) { $dtformat = strlen($change['old']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['old_'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format($dtformat); } if (!empty($change['new'])) { $dtformat = strlen($change['new']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['new_'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format($dtformat); } } // create textual representation for alarms and recurrence if ($change['property'] == 'alarms') { if (is_array($change['old'])) $change['old_'] = libcalendaring::alarm_text($change['old']); if (is_array($change['new'])) $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'recurrence') { if (is_array($change['old'])) $change['old_'] = $lib->recurrence_text($change['old']); if (is_array($change['new'])) $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'complete') { $change['old_'] = intval($change['old']) . '%'; $change['new_'] = intval($change['new']) . '%'; } if ($change['property'] == 'attachments') { if (is_array($change['old'])) $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']); if (is_array($change['new'])) { $change['new'] = array_merge((array)$change['old'], $change['new']); $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); } } // resolve parent_id to the refered task title for display if ($change['property'] == 'parent_id') { $change['property'] = 'parent-title'; if (!empty($change['old']) && ($old_parent = $this->driver->get_task(array('id' => $change['old'], 'list' => $rec['list'])))) { $change['old_'] = $old_parent['title']; } if (!empty($change['new']) && ($new_parent = $this->driver->get_task(array('id' => $change['new'], 'list' => $rec['list'])))) { $change['new_'] = $new_parent['title']; } } // compute a nice diff of description texts if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); } }); $this->rc->output->command('plugin.task_show_diff', $data); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $got_msg = true; break; case 'show': if ($rec = $this->driver->get_task_revison($rec, $rec['rev'])) { $this->encode_task($rec); $rec['readonly'] = 1; $this->rc->output->command('plugin.task_show_revision', $rec); } else { $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); } $got_msg = true; break; case 'restore': if ($success = $this->driver->restore_task_revision($rec, $rec['rev'])) { $refresh = $this->driver->get_task($rec); $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rec['rev']))), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $got_msg = true; break; } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); $this->update_counts($oldrec, $refresh); } else if (!$got_msg) { $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'); } } if ($success && $rec['_reportpartstat'] && $rec['_reportpartstat'] != 'NEEDS-ACTION') { // get the full record after update if (!$task) { $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']); if (!empty($_POST['comment'])) $task['comment'] = rcube_utils::get_input_value('comment', rcube_utils::INPUT_POST); $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'); if ($refresh) { if ($refresh['id']) { $this->encode_task($refresh); } else if (is_array($refresh)) { foreach ($refresh as $i => $r) $this->encode_task($refresh[$i]); } $this->rc->output->command('plugin.update_task', $refresh); } else if ($success && ($action == 'delete' || $action == 'undelete')) { $this->rc->output->command('plugin.refresh_tagcloud'); } } /** * 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','delegated')); $this->itip->set_rsvp_status(array('accepted','tentative','declined','delegated','in-process','completed')); } return $this->itip; } /** * repares new/edited task properties before save */ private function prepare_task($rec) { // try to be smart and extract date from raw input if ($rec['raw']) { foreach (array('today','tomorrow','sunday','monday','tuesday','wednesday','thursday','friday','saturday','sun','mon','tue','wed','thu','fri','sat') as $word) { $locwords[] = '/^' . preg_quote(mb_strtolower($this->gettext($word))) . '\b/i'; $normwords[] = $word; $datewords[] = $word; } foreach (array('jan','feb','mar','apr','may','jun','jul','aug','sep','oct','now','dec') as $month) { $locwords[] = '/(' . preg_quote(mb_strtolower($this->gettext('long'.$month))) . '|' . preg_quote(mb_strtolower($this->gettext($month))) . ')\b/i'; $normwords[] = $month; $datewords[] = $month; } foreach (array('on','this','next','at') as $word) { $fillwords[] = preg_quote(mb_strtolower($this->gettext($word))); $fillwords[] = $word; } $raw = trim($rec['raw']); $date_str = ''; // translate localized keywords $raw = preg_replace('/^(' . join('|', $fillwords) . ')\s*/i', '', $raw); $raw = preg_replace($locwords, $normwords, $raw); // find date pattern $date_pattern = '!^(\d+[./-]\s*)?((?:\d+[./-])|' . join('|', $datewords) . ')\.?(\s+\d{4})?[:;,]?\s+!i'; if (preg_match($date_pattern, $raw, $m)) { $date_str .= $m[1] . $m[2] . $m[3]; $raw = preg_replace(array($date_pattern, '/^(' . join('|', $fillwords) . ')\s*/i'), '', $raw); // add year to date string if ($m[1] && !$m[3]) $date_str .= date('Y'); } // find time pattern $time_pattern = '/^(\d+([:.]\d+)?(\s*[hapm.]+)?),?\s+/i'; if (preg_match($time_pattern, $raw, $m)) { $has_time = true; $date_str .= ($date_str ? ' ' : 'today ') . $m[1]; $raw = preg_replace($time_pattern, '', $raw); } // yes, raw input matched a (valid) date if (strlen($date_str) && strtotime($date_str) && ($date = new DateTime($date_str, $this->timezone))) { $rec['date'] = $date->format('Y-m-d'); if ($has_time) $rec['time'] = $date->format('H:i'); $rec['title'] = $raw; } else $rec['title'] = $rec['raw']; } // normalize input from client if (isset($rec['complete'])) { $rec['complete'] = floatval($rec['complete']); if ($rec['complete'] > 1) $rec['complete'] /= 100; } if (isset($rec['flagged'])) $rec['flagged'] = intval($rec['flagged']); // fix for garbage input if ($rec['description'] == 'null') $rec['description'] = ''; foreach ($rec as $key => $val) { if ($val === 'null') $rec[$key] = null; } if (!empty($rec['date'])) { $this->normalize_dates($rec, 'date', 'time'); } if (!empty($rec['startdate'])) { $this->normalize_dates($rec, 'startdate', 'starttime'); } // convert tags to array, filter out empty entries if (isset($rec['tags']) && !is_array($rec['tags'])) { $rec['tags'] = array_filter((array)$rec['tags']); } // convert the submitted alarm values if ($rec['valarms']) { $valarms = array(); foreach (libcalendaring::from_client_alarms($rec['valarms']) as $alarm) { // alarms can only work with a date (either task start, due or absolute alarm date) if (is_a($alarm['trigger'], 'DateTime') || $rec['date'] || $rec['startdate']) $valarms[] = $alarm; } $rec['valarms'] = $valarms; } // convert the submitted recurrence settings if (is_array($rec['recurrence'])) { $refdate = null; if (!empty($rec['date'])) { $refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); } else if (!empty($rec['startdate'])) { $refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); } if ($refdate) { $rec['recurrence'] = $this->lib->from_client_recurrence($rec['recurrence'], $refdate); // translate count into an absolute end date. // why? because when shifting completed tasks to the next recurrence, // the initial start date to count from gets lost. if ($rec['recurrence']['COUNT']) { $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $refdate); if ($until = $engine->end()) { $rec['recurrence']['UNTIL'] = $until; unset($rec['recurrence']['COUNT']); } } } else { // recurrence requires a reference date $rec['recurrence'] = ''; } } $attachments = array(); $taskid = $rec['id']; if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $taskid) { if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) { foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) { if (is_array($rec['attachments']) && in_array($id, $rec['attachments'])) { $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment); unset($attachments[$id]['abort'], $attachments[$id]['group']); } } } } $rec['attachments'] = $attachments; // convert link references into simple URIs if (array_key_exists('links', $rec)) { $rec['links'] = array_map(function($link) { return is_array($link) ? $link['uri'] : strval($link); }, (array)$rec['links']); } // convert invalid data if (isset($rec['attendees']) && !is_array($rec['attendees'])) $rec['attendees'] = array(); foreach ((array)$rec['attendees'] as $i => $attendee) { if (is_string($attendee['rsvp'])) { $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; } } // 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']) || (!empty($rec['attendees']) && empty($rec['organizer']))) && ($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']); return $rec; } /** * Utility method to convert a tasks date/time values into a normalized format */ private function normalize_dates(&$rec, $date_key, $time_key) { try { // parse date from user format (#2801) $date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d'); $date = DateTime::createFromFormat($date_format, trim($rec[$date_key] . ' ' . $rec[$time_key]), $this->timezone); // fall back to default strtotime logic if (empty($date)) { $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); } $rec[$date_key] = $date->format('Y-m-d'); if (!empty($rec[$time_key])) $rec[$time_key] = $date->format('H:i'); return true; } catch (Exception $e) { $rec[$date_key] = $rec[$time_key] = null; } return false; } /** * Releases some resources after successful save */ private function cleanup_task(&$rec) { // remove temp. attachment files if (!empty($_SESSION[self::SESSION_KEY]) && ($taskid = $_SESSION[self::SESSION_KEY]['id'])) { $this->rc->plugins->exec_hook('attachments_cleanup', array('group' => $taskid)); $this->rc->session->remove(self::SESSION_KEY); } } /** * When flagging a recurring task as complete, * clone it and shift dates to the next occurrence */ private function handle_recurrence(&$rec, $old) { $clone = null; if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && is_array($rec['recurrence'])) { $engine = libcalendaring::get_recurrence(); $rrule = $rec['recurrence']; $updates = array(); // compute the next occurrence of date attributes foreach (array('date'=>'time', 'startdate'=>'starttime') as $date_key => $time_key) { if (empty($rec[$date_key])) continue; $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); $engine->init($rrule, $date); if ($next = $engine->next()) { $updates[$date_key] = $next->format('Y-m-d'); if (!empty($rec[$time_key])) $updates[$time_key] = $next->format('H:i'); } } // shift absolute alarm dates if (!empty($updates) && is_array($rec['valarms'])) { $updates['valarms'] = array(); unset($rrule['UNTIL'], $rrule['COUNT']); // make recurrence rule unlimited foreach ($rec['valarms'] as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $engine->init($rrule, $alarm['trigger']); if ($next = $engine->next()) { $alarm['trigger'] = $next; } } $updates['valarms'][$i] = $alarm; } } if (!empty($updates)) { // clone task to save a completed copy $clone = $rec; $clone['uid'] = $this->generate_uid(); $clone['parent_id'] = $rec['id']; unset($clone['id'], $clone['recurrence'], $clone['attachments']); // update the task but unset completed flag $rec = array_merge($rec, $updates); $rec['complete'] = $old['complete']; $rec['status'] = $old['status']; } } 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(); $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); // 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, $task['sequence'] > $old['sequence']); // 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'] && $itip_notify & 2) { continue; } // skip if this attendee has delegated and set RSVP=FALSE if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) { continue; } // which template to use for mail text $is_new = !in_array($attendee['email'], $old_attendees); $is_rsvp = $is_new || $task['sequence'] > $old['sequence']; $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, $is_rsvp)) $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 */ public function tasklist_action() { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('l', rcube_utils::INPUT_GPC, true); $success = false; if (isset($list['showalarms'])) $list['showalarms'] = intval($list['showalarms']); switch ($action) { case 'form-new': case 'form-edit': $this->load_ui(); echo $this->ui->tasklist_editform($action, $list); exit; case 'new': $list += array('showalarms' => true, 'active' => true, 'editable' => true); if ($insert_id = $this->driver->create_list($list)) { $list['id'] = $insert_id; if (!$list['_reload']) { $this->load_ui(); $list['html'] = $this->ui->tasklist_list_item($insert_id, $list, $jsenv); $list += (array)$jsenv[$insert_id]; } $this->rc->output->command('plugin.insert_tasklist', $list); $success = true; } break; case 'edit': if ($newid = $this->driver->edit_list($list)) { $list['oldid'] = $list['id']; $list['id'] = $newid; $this->rc->output->command('plugin.update_tasklist', $list); $success = true; } break; case 'subscribe': $success = $this->driver->subscribe_list($list); break; case 'delete': if (($success = $this->driver->delete_list($list))) $this->rc->output->command('plugin.destroy_tasklist', $list); break; case 'search': $this->load_ui(); $results = array(); $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); foreach ((array)$this->driver->search_lists($query, $source) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed $prop['active'] = false; // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->tasklist_list_item($id, $prop, $jsenv); $prop += (array)$jsenv[$id]; $prop['editname'] = $editname; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($this->driver->search_more_results) { $this->rc->output->show_message('autocompletemore', 'info'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; } if ($success) $this->rc->output->show_message('successfullysaved', 'confirmation'); else $this->rc->output->show_message('tasklist.errorsaving', 'error'); $this->rc->output->command('plugin.unlock_saving'); } /** * Get counts for active tasks divided into different selectors */ public function fetch_counts() { if (isset($_REQUEST['lists'])) { $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); } else { foreach ($this->driver->get_lists() as $list) { if ($list['active']) $lists[] = $list['id']; } } $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } /** * Adjust the cached counts after changing a task */ public function update_counts($oldrec, $newrec) { // rebuild counts until this function is finally implemented $this->fetch_counts(); // $this->rc->output->command('plugin.update_counts', $counts); } /** * */ public function fetch_tasks() { $mask = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = array('mask' => $mask, 'search' => $search); $data = $this->tasks_data($this->driver->list_tasks($filter, $lists)); $this->rc->output->command('plugin.data_ready', array( 'filter' => $mask, 'lists' => $lists, 'search' => $search, 'data' => $data, 'tags' => $this->driver->get_tags(), )); } /** * Handler for printing calendars */ public function print_tasks() { // Add CSS stylesheets to the page header $skin_path = $this->local_skin_path(); $this->include_stylesheet($skin_path . '/print.css'); $this->include_script('tasklist.js'); $this->rc->output->add_handlers(array( 'plugin.tasklist_print' => array($this, 'print_tasks_list'), )); $this->rc->output->set_pagetitle($this->gettext('print')); $this->rc->output->send('tasklist.print'); } /** * Handler for printing calendars */ public function print_tasks_list($attrib) { $mask = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = array('mask' => $mask, 'search' => $search); $data = $this->tasks_data($this->driver->list_tasks($filter, $lists)); // we'll build the tasks table in javascript on page load // where we have sorting methods, etc. $this->rc->output->set_env('tasks', $data); $this->rc->output->set_env('filtermask', $mask); return $this->ui->tasks_resultview($attrib); } /** * Prepare and sort the given task records to be sent to the client */ private function tasks_data($records) { $data = $this->task_tree = $this->task_titles = array(); foreach ($records as $rec) { if ($rec['parent_id']) { $this->task_tree[$rec['id']] = $rec['parent_id']; } $this->encode_task($rec); $data[] = $rec; } // assign hierarchy level indicators for later sorting array_walk($data, array($this, 'task_walk_tree')); return $data; } /** * Prepare the given task record before sending it to the client */ private function encode_task(&$rec) { $rec['mask'] = $this->filter_mask($rec); $rec['flagged'] = intval($rec['flagged']); $rec['complete'] = floatval($rec['complete']); if (is_object($rec['created'])) { $rec['created_'] = $this->rc->format_date($rec['created']); $rec['created'] = $rec['created']->format('U'); } if (is_object($rec['changed'])) { $rec['changed_'] = $this->rc->format_date($rec['changed']); $rec['changed'] = $rec['changed']->format('U'); } else { $rec['changed'] = null; } if ($rec['date']) { try { $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); $rec['datetime'] = intval($date->format('U')); $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); $rec['_hasdate'] = 1; } catch (Exception $e) { $rec['date'] = $rec['datetime'] = null; } } else { $rec['date'] = $rec['datetime'] = null; $rec['_hasdate'] = 0; } if ($rec['startdate']) { try { $date = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); $rec['startdatetime'] = intval($date->format('U')); $rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); } catch (Exception $e) { $rec['startdate'] = $rec['startdatetime'] = null; } } if ($rec['valarms']) { $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']); $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']); } if ($rec['recurrence']) { $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']); $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], $rec['time'] || $rec['starttime']); } foreach ((array)$rec['attachments'] as $k => $attachment) { $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); } // convert link URIs references into structs if (array_key_exists('links', $rec)) { foreach ((array) $rec['links'] as $i => $link) { if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link, 'task'))) { $rec['links'][$i] = $msgref; } } } // Convert HTML description into plain text if ($this->is_html($rec)) { $h2t = new rcube_html2text($rec['description'], false, true, 0); $rec['description'] = $h2t->get_text(); } if (!is_array($rec['tags'])) $rec['tags'] = (array)$rec['tags']; sort($rec['tags'], SORT_LOCALE_STRING); if (in_array($rec['id'], $this->collapsed_tasks)) $rec['collapsed'] = true; if (empty($rec['parent_id'])) $rec['parent_id'] = null; $this->task_titles[$rec['id']] = $rec['title']; } /** * Determine whether the given task description is HTML formatted */ private function is_html($task) { // check for opening and closing or tags return (preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '') > 0); } /** * Callback function for array_walk over all tasks. * Sets tree depth and parent titles */ private function task_walk_tree(&$rec) { $rec['_depth'] = 0; $parent_titles = array(); $parent_id = $this->task_tree[$rec['id']]; while ($parent_id) { $rec['_depth']++; array_unshift($parent_titles, $this->task_titles[$parent_id]); $parent_id = $this->task_tree[$parent_id]; } if (count($parent_titles)) { $rec['parent_title'] = join(' ยป ', array_filter($parent_titles)); } } /** * Compute the filter mask of the given task * * @param array Hash array with Task record properties * @return int Filter mask */ public function filter_mask($rec) { static $today, $today_date, $tomorrow, $weeklimit; if (!$today) { $today_date = new DateTime('now', $this->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new DateTime('now + 1 day', $this->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); // In Kolab-mode we hide "Next 7 days" filter, which means // "Later" should catch tasks with date after tomorrow (#5353) if ($this->rc->output->get_env('tasklist_driver') == 'kolab') { $weeklimit = $tomorrow; } else { $week_date = new DateTime('now + 7 days', $this->timezone); $weeklimit = $week_date->format('Y-m-d'); } } $mask = 0; $start = $rec['startdate'] ?: '1900-00-00'; $duedate = $rec['date'] ?: '3000-00-00'; if ($rec['flagged']) $mask |= self::FILTER_MASK_FLAGGED; if ($this->driver->is_complete($rec)) $mask |= self::FILTER_MASK_COMPLETE; if (empty($rec['date'])) $mask |= self::FILTER_MASK_NODATE; else if ($rec['date'] < $today) $mask |= self::FILTER_MASK_OVERDUE; if (empty($rec['recurrence']) || $duedate < $today || $start > $weeklimit) { if ($duedate <= $today || ($rec['startdate'] && $start <= $today)) $mask |= self::FILTER_MASK_TODAY; else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow)) $mask |= self::FILTER_MASK_TOMORROW; else if (($start > $tomorrow && $start <= $weeklimit) || ($duedate > $tomorrow && $duedate <= $weeklimit)) $mask |= self::FILTER_MASK_WEEK; else if ($start > $weeklimit || $duedate > $weeklimit) $mask |= self::FILTER_MASK_LATER; } else if ($rec['startdate'] || $rec['date']) { $date = new DateTime($rec['startdate'] ?: $rec['date'], $this->timezone); // set safe recurrence start while ($date->format('Y-m-d') >= $today) { switch ($rec['recurrence']['FREQ']) { case 'DAILY': $date = clone $today_date; $date->sub(new DateInterval('P1D')); break; case 'WEEKLY': $date->sub(new DateInterval('P7D')); break; case 'MONTHLY': $date->sub(new DateInterval('P1M')); break; case 'YEARLY': $date->sub(new DateInterval('P1Y')); break; default; break 2; } } $date->_dateonly = true; $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $date); // check task occurrences (stop next week) // FIXME: is there a faster way of doing this? while ($date = $engine->next()) { $date = $date->format('Y-m-d'); // break iteration asap if ($date > $duedate || ($mask & self::FILTER_MASK_LATER)) { break; } if ($date == $today) { $mask |= self::FILTER_MASK_TODAY; } else if ($date == $tomorrow) { $mask |= self::FILTER_MASK_TOMORROW; } else if ($date > $tomorrow && $date <= $weeklimit) { $mask |= self::FILTER_MASK_WEEK; } else if ($date > $weeklimit) { $mask |= self::FILTER_MASK_LATER; break; } } } // add masks for assigned tasks if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) $mask |= self::FILTER_MASK_ASSIGNED; else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) $mask |= self::FILTER_MASK_MYTASKS; 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 ********/ /** * Render main view of the tasklist task */ public function tasklist_view() { $this->ui->init(); $this->ui->init_templates(); // set autocompletion env $this->rc->output->set_env('autocomplete_threads', (int)$this->rc->config->get('autocomplete_threads', 0)); $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15)); $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length')); $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'close'); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('tasklist.mainview'); } /** * */ public function get_inline_ui() { foreach (array('save','cancel','savingdata') as $label) $texts['tasklist.'.$label] = $this->gettext($label); $texts['tasklist.newtask'] = $this->gettext('createfrommail'); $this->ui->init_templates(); $this->ui->tasklists(); // collect env variables $env = array( 'tasklists' => $this->rc->output->get_env('tasklists'), 'tasklist_settings' => $this->ui->load_settings(), ); echo $this->api->output->parse('tasklist.taskedit', false, false); $script_add = ''; foreach ($this->ui->get_gui_objects() as $obj => $id) { $script_add .= rcmail_output::JS_OBJECT_NAME . ".gui_object('$obj', '$id');\n"; } echo html::tag('script', array('type' => 'text/javascript'), rcmail_output::JS_OBJECT_NAME . ".set_env(" . json_encode($env) . ");\n". rcmail_output::JS_OBJECT_NAME . ".add_label(" . json_encode($texts) . ");\n". $script_add ); exit; } /** * Handler for keep-alive requests * This will check for updated data in active lists and sync them to the client */ public function refresh($attr) { // refresh the entire list every 10th time to also sync deleted items if (rand(0,10) == 10) { $this->rc->output->command('plugin.reload_data'); return; } $filter = array( 'since' => $attr['last'], 'search' => rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), 'mask' => intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)) & self::FILTER_MASK_COMPLETE, ); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);; $updates = $this->driver->list_tasks($filter, $lists); if (!empty($updates)) { $this->rc->output->command('plugin.refresh_tasks', $this->tasks_data($updates), true); // update counts $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } } /** * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests. * This will check for pending notifications and pass them to the client */ public function pending_alarms($p) { $this->load_driver(); if ($alarms = $this->driver->pending_alarms($p['time'] ?: time())) { foreach ($alarms as $alarm) { // encode alarm object to suit the expectations of the calendaring code if ($alarm['date']) $alarm['start'] = new DateTime($alarm['date'].' '.$alarm['time'], $this->timezone); $alarm['id'] = 'task:' . $alarm['id']; // prefix ID with task: $alarm['allday'] = empty($alarm['time']) ? 1 : 0; $p['alarms'][] = $alarm; } } return $p; } /** * Handler for alarm dismiss hook triggered by the calendar module */ public function dismiss_alarms($p) { $this->load_driver(); foreach ((array)$p['ids'] as $id) { if (strpos($id, 'task:') === 0) $p['success'] |= $this->driver->dismiss_alarm(substr($id, 5), $p['snooze']); } return $p; } /** * Handler for importing .ics files */ function import_tasks() { // Upload progress update if (!empty($_GET['_progress'])) { $this->rc->upload_progress(); } @set_time_limit(0); // process uploaded file if there is no error $err = $_FILES['_data']['error']; if (!$err && $_FILES['_data']['tmp_name']) { $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); $lists = $this->driver->get_lists(); $list = $lists[$source] ?: $this->get_default_tasklist(); $source = $list['id']; // extract zip file if ($_FILES['_data']['type'] == 'application/zip') { $count = 0; if (class_exists('ZipArchive', false)) { $zip = new ZipArchive(); if ($zip->open($_FILES['_data']['tmp_name'])) { $randname = uniqid('zip-' . session_id(), true); $tmpdir = slashify($this->rc->config->get('temp_dir', sys_get_temp_dir())) . $randname; mkdir($tmpdir, 0700); // extract each ical file from the archive and import it for ($i = 0; $i < $zip->numFiles; $i++) { $filename = $zip->getNameIndex($i); if (preg_match('/\.ics$/i', $filename)) { $tmpfile = $tmpdir . '/' . basename($filename); if (copy('zip://' . $_FILES['_data']['tmp_name'] . '#'.$filename, $tmpfile)) { $count += $this->import_from_file($tmpfile, $source, $errors); unlink($tmpfile); } } } rmdir($tmpdir); $zip->close(); } else { $errors = 1; $msg = 'Failed to open zip file.'; } } else { $errors = 1; $msg = 'Zip files are not supported for import.'; } } else { // attempt to import the uploaded file directly $count = $this->import_from_file($_FILES['_data']['tmp_name'], $source, $errors); } if ($count) { $this->rc->output->command('display_message', $this->gettext(array('name' => 'importsuccess', 'vars' => array('nr' => $count))), 'confirmation'); $this->rc->output->command('plugin.import_success', array('source' => $source, 'refetch' => true)); } else if (!$errors) { $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); $this->rc->output->command('plugin.import_success', array('source' => $source)); } else { $this->rc->output->command('plugin.import_error', array('message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : ''))); } } else { if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $msg = $this->rc->gettext(array('name' => 'filesizeerror', 'vars' => array( 'size' => $this->rc->show_bytes(parse_bytes(ini_get('upload_max_filesize')))))); } else { $msg = $this->rc->gettext('fileuploaderror'); } $this->rc->output->command('plugin.import_error', array('message' => $msg)); } $this->rc->output->send('iframe'); } /** * Helper function to parse and import a single .ics file */ private function import_from_file($filepath, $source, &$errors) { $user_email = $this->rc->user->get_username(); $ical = $this->get_ical(); $errors = !$ical->fopen($filepath); $count = $i = 0; foreach ($ical as $task) { // keep the browser connection alive on long import jobs if (++$i > 100 && $i % 100 == 0) { echo ""; ob_flush(); } if ($task['_type'] == 'task') { $task['list'] = $source; if ($this->driver->create_task($task)) { $count++; } else { $errors++; } } } return $count; } /** * Construct the ics file for exporting tasks to iCalendar format */ function export_tasks() { $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); $task_id = rcube_utils::get_input_value('id', rcube_utils::INPUT_GPC); $attachments = (bool) rcube_utils::get_input_value('attachments', rcube_utils::INPUT_GPC); $this->load_driver(); $browser = new rcube_browser; $lists = $this->driver->get_lists(); $tasks = array(); $filter = array(); // get message UIDs for filter if ($source && ($list = $lists[$source])) { $filename = html_entity_decode($list['name']) ?: $sorce; $filter = array($source => true); } else if ($task_id) { $filename = 'tasks'; foreach (explode(',', $task_id) as $id) { list($list_id, $task_id) = explode(':', $id, 2); if ($list_id && $task_id) { $filter[$list_id][] = $task_id; } } } // Get tasks foreach ($filter as $list_id => $uids) { $_filter = is_array($uids) ? array('uid' => $uids) : null; $_tasks = $this->driver->list_tasks($_filter, $list_id); if (!empty($_tasks)) { $tasks = array_merge($tasks, $_tasks); } } // Set file name if ($source && count($tasks) == 1) { $filename = $tasks[0]['title'] ?: 'task'; } $filename .= '.ics'; $filename = $browser->ie ? rawurlencode($filename) : addcslashes($filename, '"'); $tasks = array_map(array($this, 'to_libcal'), $tasks); // Give plugins a possibility to implement other output formats or modify the result $plugin = $this->rc->plugins->exec_hook('tasks_export', array( 'result' => $tasks, 'attachments' => $attachments, 'filename' => $filename, 'plugin' => $this, )); if ($plugin['abort']) { exit; } $this->rc->output->nocacheing_headers(); // don't kill the connection if download takes more than 30 sec. @set_time_limit(0); header("Content-Type: text/calendar"); header("Content-Disposition: inline; filename=\"". $plugin['filename'] ."\""); $this->get_ical()->export($plugin['result'], '', true, $plugins['attachments'] ? array($this->driver, 'get_attachment_body') : null); exit; } /******* Attachment handling *******/ /** * Handler for attachments upload */ public function attachment_upload() { - $this->lib->attachment_upload(self::SESSION_KEY); + $handler = new kolab_attachments_handler(); + $handler->attachment_upload(self::SESSION_KEY); } /** * Handler for attachments download/displaying */ public function attachment_get() { + $handler = new kolab_attachments_handler(); + // show loading page if (!empty($_GET['_preload'])) { - return $this->lib->attachment_loading_page(); + return $handler->attachment_loading_page(); } $task = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); - $task = array('id' => $task, 'list' => $list, 'rev' => $rev); + $task = array('id' => $task, 'list' => $list, 'rev' => $rev); $attachment = $this->driver->get_attachment($id, $task); // show part page if (!empty($_GET['_frame'])) { - $this->lib->attachment = $attachment; - $this->register_handler('plugin.attachmentframe', array($this->lib, 'attachment_frame')); - $this->register_handler('plugin.attachmentcontrols', array($this->lib, 'attachment_header')); - $this->rc->output->set_env('filename', $attachment['name']); - $this->rc->output->set_env('mimetype', $attachment['mimetype']); - $this->rc->output->send('tasklist.attachment'); + $handler->attachment_page($attachment); } // deliver attachment content else if ($attachment) { $attachment['body'] = $this->driver->get_attachment_body($id, $task); - $this->lib->attachment_get($attachment); + $handler->attachment_get($attachment); } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /******* Email related function *******/ public function mail_message2task() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $task = array(); // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); $message = new rcube_message($uid); if ($message->headers) { $task['title'] = trim($message->subject); $task['description'] = trim($message->first_text_part()); $task['id'] = -$uid; $this->load_driver(); // add a reference to the email message if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { $task['links'] = array($msgref); } // copy mail attachments to task else if ($message->attachments && $this->driver->attachments) { if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $task['id']) { $_SESSION[self::SESSION_KEY] = array(); $_SESSION[self::SESSION_KEY]['id'] = $task['id']; $_SESSION[self::SESSION_KEY]['attachments'] = array(); } foreach ((array)$message->attachments as $part) { $attachment = array( 'data' => $imap->get_message_part($uid, $part->mime_id, $part), 'size' => $part->size, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'group' => $task['id'], ); $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); if ($attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); // store new attachment in session unset($attachment['status'], $attachment['abort'], $attachment['data']); $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment; $attachment['id'] = 'rcmfile' . $attachment['id']; // add prefix to consider it 'new' $task['attachments'][] = $attachment; } } } $this->rc->output->command('plugin.mail2taskdialog', $task); } else { $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); } $this->rc->output->send(); } /** * Add UI element to copy task invitations or updates to the tasklist */ public function mail_messagebody_html($p) { // load iCalendar functions (if necessary) if (!empty($this->lib->ical_parts)) { $this->get_ical(); $this->load_itip(); } $html = ''; $has_tasks = false; $ical_objects = $this->lib->get_mail_ical_objects(); // show a box for every task in the file foreach ($ical_objects as $idx => $task) { if ($task['_type'] != 'task') { continue; } $has_tasks = true; // get prepared inline UI for this event object if ($ical_objects->method) { $html .= html::div('tasklist-invitebox invitebox boxinformation', $this->itip->mail_itip_inline_ui( $task, $ical_objects->method, $ical_objects->mime_id . ':' . $idx, 'tasks', rcube_utils::anytodatetime($ical_objects->message_date) ) ); } // limit listing if ($idx >= 3) { break; } } // list linked tasks $links = array(); foreach ($this->message_tasks as $task) { $checkbox = new html_checkbox(array( 'name' => 'completed', 'class' => 'complete', 'title' => $this->gettext('complete'), 'data-list' => $task['list'], )); $complete = $this->driver->is_complete($task); $links[] = html::tag('li', 'messagetaskref' . ($complete ? ' complete' : ''), $checkbox->show($complete ? $task['id'] : null, array('value' => $task['id'])) . ' ' . html::a(array( 'href' => $this->rc->url(array( 'task' => 'tasks', 'list' => $task['list'], 'id' => $task['id'], )), 'class' => 'messagetasklink', 'rel' => $task['id'] . '@' . $task['list'], 'target' => '_blank', ), rcube::Q($task['title'])) ); } if (count($links)) { $html .= html::div('messagetasklinks', html::tag('ul', 'tasklist', join("\n", $links))); } // prepend iTip/relation 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; } /** * Lookup backend storage and find notes associated with the given message */ public function mail_message_load($p) { if (!$p['object']->headers->others['x-kolab-type']) { $this->load_driver(); $this->message_tasks = $this->driver->get_message_related_tasks($p['object']->headers, $p['object']->folder); // sort message tasks by completeness and due date $driver = $this->driver; array_walk($this->message_tasks, array($this, 'encode_task')); usort($this->message_tasks, function($a, $b) use ($driver) { $a_complete = intval($driver->is_complete($a)); $b_complete = intval($driver->is_complete($b)); $d = $a_complete - $b_complete; if (!$d) $d = $b['_hasdate'] - $a['_hasdate']; if (!$d) $d = $a['datetime'] - $b['datetime']; return $d; }); } } /** * 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($sensitivity = null, $lists = null) { if ($lists === null) { $lists = $this->driver->get_lists(tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_WRITEABLE); } $list = null; foreach ($lists as $l) { if ($sensitivity && $l['subtype'] == $sensitivity) { $list = $l; break; } if ($l['default']) { $list = $l; } if ($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 = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($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(); foreach ($tasks as $task) { // save to tasklist $list = $lists[$cal_id] ?: $this->get_default_tasklist($task['sensitivity']); 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); $comment = rcube_utils::get_input_value('_comment', 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; if ($status == 'delegated') { $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false); $delegate = reset($delegates); if (empty($delegate) || empty($delegate['mailto'])) { $this->rc->output->command('display_message', $this->gettext('libcalendaring.delegateinvalidaddress'), 'error'); return; } } // successfully parsed tasks? if ($task = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'task')) { $task = $this->from_ical($task); // forward iTip request to delegatee if ($delegate) { $rsvpme = rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $task['comment'] = $comment; if ($itip->delegate_to($task, $delegate, !empty($rsvpme))) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } unset($task['comment']); } $mode = tasklist_driver::FILTER_PERSONAL | tasklist_driver::FILTER_SHARED | tasklist_driver::FILTER_WRITEABLE; // find writeable list to store the task $list_id = !empty($_REQUEST['_folder']) ? rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists($mode); $list = $lists[$list_id]; $dontsave = ($_REQUEST['_folder'] === '' && $task['_method'] == 'REQUEST'); // select default list except user explicitly selected 'none' if (!$list && !$dontsave) { $list = $this->get_default_tasklist($task['sensitivity'], $lists); } $metadata = array( 'uid' => $task['uid'], 'changed' => is_object($task['changed']) ? $task['changed']->format('U') : 0, 'sequence' => intval($task['sequence']), 'fallback' => strtoupper($status), 'method' => $task['_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 (!in_array($task['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED'))) { $task['attendees'][$i]['rsvp'] = false; // unset RSVP attribute } } } // add attendee with this user's default identity if not listed if (!$reply_sender) { $sender_identity = $this->rc->user->list_emails(true); $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->find_task($task['uid'], $mode); if ($existing) { // only update attendee status if ($task['_method'] == 'REPLY') { // try to identify the attendee using the email sender address $existing_attendee = -1; $existing_attendee_emails = array(); foreach ($existing['attendees'] as $i => $attendee) { $existing_attendee_emails[] = $attendee['email']; if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $existing_attendee = $i; } } $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'; if ($attendee['status'] != 'DELEGATED') { break; } } // also copy delegate attendee else if (!empty($attendee['delegated-from']) && (stripos($attendee['delegated-from'], $task['_sender']) !== false || stripos($attendee['delegated-from'], $task['_sender_utf']) !== false) && (!in_array($attendee['email'], $existing_attendee_emails))) { $existing['attendees'][] = $attendee; } } // if delegatee has declined, set delegator's RSVP=True if ($task_attendee && $task_attendee['status'] == 'DECLINED' && $task_attendee['delegated-from']) { foreach ($existing['attendees'] as $i => $attendee) { if ($attendee['email'] == $task_attendee['delegated-from']) { $existing['attendees'][$i]['rsvp'] = true; 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)) { $this->lib->merge_attendees($task, $existing); } // set status=CANCELLED on CANCEL messages if ($task['_method'] == 'CANCEL') { $task['status'] = 'CANCELLED'; } // update attachments list, allow attachments update only on REQUEST (#5342) if ($task['_method'] == 'REQUEST') { $task['deleted_attachments'] = true; } else { unset($task['attachments']); } // 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' || $dontsave) { $error_msg = null; } else { $error_msg = $this->gettext('nowritetasklistfound'); } } if ($success || $dontsave) { if ($success) { $message = $task['_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 ($task['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { $task['comment'] = $comment; $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 task/itip-delegate requests */ function mail_itip_delegate() { // forward request to mail_import_itip() with the right status $_POST['_status'] = $_REQUEST['_status'] = 'delegated'; $this->mail_import_itip(); } /** * Find a task in user tasklists */ protected function find_task($task, &$mode) { $this->load_driver(); // We search for writeable folders in personal namespace by default $mode = tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_PERSONAL; $result = $this->driver->get_task($task, $mode); // ... now check shared folders if not found if (!$result) { $result = $this->driver->get_task($task, tasklist_driver::FILTER_WRITEABLE | tasklist_driver::FILTER_SHARED); if ($result) { $mode |= tasklist_driver::FILTER_SHARED; } } return $result; } /** * Handler for task/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->find_task($data, $mode); $is_shared = $mode & tasklist_driver::FILTER_SHARED; $itip = $this->load_itip(); $response = $itip->get_itip_status($data, $existing); // get a list of writeable lists to save new tasks to if ((!$existing || $is_shared) && $response['action'] == 'rsvp' || $response['action'] == 'import') { $lists = $this->driver->get_lists($mode); $select = new html_select(array('name' => 'tasklist', 'id' => 'itip-saveto', 'is_escaped' => true, 'class' => 'form-control')); $select->add('--', ''); foreach ($lists as $list) { if ($list['editable']) { $select->add($list['name'], $list['id']); } } } if ($select) { $default_list = $this->get_default_tasklist($data['sensitivity'], $lists); $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . ' ' . $select->show($is_shared ? $existing['list'] : $default_list['id'])); } $this->rc->output->command('plugin.update_itip_object_status', $response); } /** * Handler for task/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 *******/ /** * Generate a unique identifier for an event */ public function generate_uid() { 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 */ public function user_delete($args) { $this->load_driver(); return $this->driver->user_delete($args); } /** * Magic getter for public access to protected members */ public function __get($name) { switch ($name) { case 'ical': return $this->get_ical(); case 'itip': return $this->load_itip(); case 'driver': $this->load_driver(); return $this->driver; } return null; } }