diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php index b8fe13db..3b05cf03 100644 --- a/plugins/calendar/drivers/caldav/caldav_driver.php +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -687,6 +687,6 @@ class caldav_driver extends kolab_driver ], ]; - return kolab_utils::folder_form($form, $folder, 'calendar', [], true); + return kolab_utils::folder_form($form, '', 'calendar', [], true); } } diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php index 83779834..ac7687c8 100644 --- a/plugins/calendar/lib/calendar_ui.php +++ b/plugins/calendar/lib/calendar_ui.php @@ -30,6 +30,9 @@ class calendar_ui private $ready = false; public $screen; + public $action; + public $calendar; + function __construct($cal) { diff --git a/plugins/libcalendaring/lib/libcalendaring_vcalendar.php b/plugins/libcalendaring/lib/libcalendaring_vcalendar.php index f344b41f..2a79f36d 100644 --- a/plugins/libcalendaring/lib/libcalendaring_vcalendar.php +++ b/plugins/libcalendaring/lib/libcalendaring_vcalendar.php @@ -33,8 +33,8 @@ use \Sabre\VObject\DateTimeParser; class libcalendaring_vcalendar implements Iterator { private $timezone; - private $attach_uri = null; - private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; + private $attach_uri; + private $prodid; private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $attendee_keymap = array( 'name' => 'CN', @@ -73,7 +73,7 @@ class libcalendaring_vcalendar implements Iterator function __construct($tz = null) { $this->timezone = $tz; - $this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; + $this->prodid = '-//Roundcube ' . RCUBE_VERSION . '//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } /** diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql index 0936cd8d..f45ccf66 100644 --- a/plugins/libkolab/SQL/mysql.initial.sql +++ b/plugins/libkolab/SQL/mysql.initial.sql @@ -210,6 +210,24 @@ CREATE TABLE `kolab_cache_dav_event` ( PRIMARY KEY(`folder_id`,`uid`) ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +DROP TABLE IF EXISTS `kolab_cache_dav_task`; + +CREATE TABLE `kolab_cache_dav_task` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `dtstart` DATETIME, + `dtend` DATETIME, + CONSTRAINT `fk_kolab_cache_dav_task_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + SET FOREIGN_KEY_CHECKS=1; -REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022100500'); +REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022122800'); diff --git a/plugins/libkolab/SQL/mysql/2022122800.sql b/plugins/libkolab/SQL/mysql/2022122800.sql new file mode 100644 index 00000000..42127383 --- /dev/null +++ b/plugins/libkolab/SQL/mysql/2022122800.sql @@ -0,0 +1,17 @@ +DROP TABLE IF EXISTS `kolab_cache_dav_task`; + +CREATE TABLE `kolab_cache_dav_task` ( + `folder_id` BIGINT UNSIGNED NOT NULL, + `uid` VARCHAR(512) NOT NULL, + `etag` VARCHAR(128) DEFAULT NULL, + `created` DATETIME DEFAULT NULL, + `changed` DATETIME DEFAULT NULL, + `data` LONGTEXT NOT NULL, + `tags` TEXT NOT NULL, + `words` TEXT NOT NULL, + `dtstart` DATETIME, + `dtend` DATETIME, + CONSTRAINT `fk_kolab_cache_dav_task_folder` FOREIGN KEY (`folder_id`) + REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY(`folder_id`,`uid`) +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php index c5d3fb29..1db25549 100644 --- a/plugins/libkolab/lib/kolab_dav_client.php +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -264,9 +264,9 @@ class kolab_dav_client foreach ($response->getElementsByTagName('response') as $element) { $folder = $this->getFolderPropertiesFromResponse($element); - // Note: Addressbooks don't have 'type' specified + // Note: Addressbooks don't have 'types' specified if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type'])) - || $folder['type'] === $component + || in_array($component, (array) $folder['types']) ) { $folders[] = $folder; } @@ -296,18 +296,7 @@ class kolab_dav_client $response = $this->request($location, 'PUT', $content, $headers); - if ($response !== false) { - // Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456 - $etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null; - - if (is_string($etag) && preg_match('|^".*"$|', $etag)) { - $etag = substr($etag, 1, -1); - } - - return $etag; - } - - return false; + return $this->getETagFromResponse($response); } /** @@ -338,6 +327,23 @@ class kolab_dav_client return $response !== false; } + /** + * Move a DAV object + * + * @param string $source Source object location + * @param string $target Target object content + * + * @return false|string|null ETag string (or NULL) on success, False on error + */ + public function move($source, $target) + { + $headers = ['Destination' => $target]; + + $response = $this->request($source, 'MOVE', '', $headers); + + return $this->getETagFromResponse($response); + } + /** * Get folder properties. * @@ -665,10 +671,10 @@ class kolab_dav_client $ctag = $ctag->nodeValue; } - $component = null; + $components = []; if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { - if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) { - $component = $comp_element->attributes->getNamedItem('name')->nodeValue; + foreach ($set_element->getElementsByTagName('comp') as $comp_element) { + $components[] = $comp_element->attributes->getNamedItem('name')->nodeValue; } } @@ -685,7 +691,7 @@ class kolab_dav_client 'name' => $name, 'ctag' => $ctag, 'color' => $color, - 'type' => $component, + 'types' => $components, 'resource_type' => $types, ]; @@ -741,6 +747,25 @@ class kolab_dav_client ]; } + /** + * Get ETag from a response + */ + protected function getETagFromResponse($response) + { + if ($response !== false) { + // Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456 + $etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null; + + if (is_string($etag) && preg_match('|^".*"$|', $etag)) { + $etag = substr($etag, 1, -1); + } + + return $etag; + } + + return false; + } + /** * Initialize HTTP request object */ diff --git a/plugins/libkolab/lib/kolab_format.php b/plugins/libkolab/lib/kolab_format.php index a6dbcbff..5d98fb15 100644 --- a/plugins/libkolab/lib/kolab_format.php +++ b/plugins/libkolab/lib/kolab_format.php @@ -751,7 +751,7 @@ abstract class kolab_format } // in kolab_storage attachments are indexed by content-id - foreach ((array) $object['attachments'] as $attachment) { + foreach ((array) ($object['attachments'] ?? []) as $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php index e38c4190..859249f1 100644 --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -92,8 +92,9 @@ class kolab_storage_cache $rcmail->add_shutdown_function(array($this, '_sync_unlock')); } - if ($storage_folder) + if ($storage_folder) { $this->set_folder($storage_folder); + } } /** @@ -1261,15 +1262,13 @@ class kolab_storage_cache $read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?"; $write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?"; - $max_lock_time = $this->_max_sync_lock_time(); - $sync_lock = intval($this->metadata['synclock'] ?? 0); // wait if locked (expire locks after 10 minutes) ... // ... or if setting lock fails (another process meanwhile set it) while ( - ($sync_lock + $max_lock_time > time()) || - (($res = $this->db->query($write_query, time(), $this->folder_id, $sync_lock)) + (intval($this->metadata['synclock'] ?? 0) + $max_lock_time > time()) || + (($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock'] ?? 0))) && !($affected = $this->db->affected_rows($res)) ) ) { diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php index 0845b9ac..68a97708 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -157,9 +157,10 @@ class kolab_storage_dav_cache extends kolab_storage_cache } } + $i = 0; + // Fetch new objects and store in DB if (!empty($new_index)) { - $i = 0; foreach (array_chunk($new_index, $chunk_size, true) as $chunk) { $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); @@ -707,4 +708,38 @@ class kolab_storage_dav_cache extends kolab_storage_cache return $object; } + + /** + * Read this folder's ID and cache metadata + */ + protected function _read_folder_data() + { + // already done + if (!empty($this->folder_id) || !$this->ready) { + return; + } + + // Different than in Kolab XML-based storage, in *DAV folders can + // contain different types of data, e.g. Calendar can store events and tasks. + // Therefore we both `resource` and `type` in WHERE. + + $sql_arr = $this->db->fetch_assoc($this->db->query( + "SELECT `folder_id`, `synclock`, `ctag`, `changed` FROM `{$this->folders_table}`" + . " WHERE `resource` = ? AND `type` = ?", + $this->resource_uri, + $this->folder->type + )); + + if ($sql_arr) { + $this->folder_id = $sql_arr['folder_id']; + $this->metadata = $sql_arr; + } + else { + $this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)" + . " VALUES (?, ?)", $this->resource_uri, $this->folder->type); + + $this->folder_id = $this->db->insert_id('kolab_folders'); + $this->metadata = []; + } + } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_task.php b/plugins/libkolab/lib/kolab_storage_dav_cache_task.php new file mode 100644 index 00000000..729998a9 --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_dav_cache_task.php @@ -0,0 +1,112 @@ + + * + * Copyright (C) 2013-2022 Apheleia IT 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_storage_dav_cache_task extends kolab_storage_dav_cache +{ + protected $extra_cols = ['dtstart','dtend']; + protected $data_props = ['categories', 'status', 'complete', 'start', 'due']; + protected $fulltext_cols = ['title', 'description', 'categories']; + + /** + * Helper method to convert the given Kolab object into a dataset to be written to cache + * + * @override + */ + protected function _serialize($object) + { + $sql_data = parent::_serialize($object); + + $sql_data['dtstart'] = !empty($object['start']) ? $this->_convert_datetime($object['start']) : null; + $sql_data['dtend'] = !empty($object['due']) ? $this->_convert_datetime($object['due']) : null; + + $sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search + $sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' '; + + return $sql_data; + } + + /** + * Callback to get words to index for fulltext search + * + * @return array List of words to save in cache + */ + public function get_words($object = []) + { + $data = ''; + + foreach ($this->fulltext_cols as $colname) { + list($col, $field) = strpos($colname, ':') ? explode(':', $colname) : [$colname, null]; + + if (empty($object[$col])) { + continue; + } + + if ($field) { + $a = []; + foreach ((array) $object[$col] as $attr) { + if (!empty($attr[$field])) { + $a[] = $attr[$field]; + } + } + $val = join(' ', $a); + } + else { + $val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col]; + } + + if (is_string($val) && strlen($val)) { + $data .= $val . ' '; + } + } + + $words = rcube_utils::normalize_string($data, true); + + return array_unique($words); + } + + /** + * Callback to get object specific tags to cache + * + * @return array List of tags to save in cache + */ + public function get_tags($object) + { + $tags = []; + + if ((isset($object['status']) && $object['status'] == 'COMPLETED') + || (isset($object['complete']) && $object['complete'] == 100 && empty($object['status'])) + ) { + $tags[] = 'x-complete'; + } + + if (!empty($object['priority']) && $object['priority'] == 1) { + $tags[] = 'x-flagged'; + } + + if (!empty($object['parent_id'])) { + $tags[] = 'x-parent:' . $object['parent_id']; + } + + return array_unique($tags); + } +} diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index fd41de65..6877bdc5 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -20,6 +20,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + +#[AllowDynamicProperties] class kolab_storage_dav_folder extends kolab_storage_folder { public $dav; @@ -310,8 +312,8 @@ class kolab_storage_dav_folder extends kolab_storage_folder /** * Move a Kolab object message to another IMAP folder * - * @param string Object UID - * @param string IMAP folder to move object to + * @param string Object UID + * @param kolab_storage_dav_folder Target folder to move object into * * @return bool True on success, false on failure */ @@ -321,9 +323,16 @@ class kolab_storage_dav_folder extends kolab_storage_folder return false; } - // TODO + $source = $this->object_location($uid); + $target = $target_folder->object_location($uid); - return false; + $success = $this->dav->move($source, $target) !== false; + + if ($success) { + $this->cache->set($uid, false); + } + + return $success; } /** @@ -466,15 +475,15 @@ class kolab_storage_dav_folder extends kolab_storage_folder return false; } - if ($this->type == 'event') { + if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); - $events = $ical->import($object['data']); + $objects = $ical->import($object['data']); - if (!count($events) || empty($events[0]['uid'])) { + if (!count($objects) || empty($objects[0]['uid'])) { return false; } - $result = $events[0]; + $result = $objects[0]; $result['_attachments'] = $result['attachments'] ?? []; unset($result['attachments']); @@ -550,12 +559,15 @@ class kolab_storage_dav_folder extends kolab_storage_folder { $result = ''; - if ($this->type == 'event') { + if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); + if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } + $object['_type'] = $this->type; + // pre-process attachments if (isset($object['_attachments']) && is_array($object['_attachments'])) { foreach ($object['_attachments'] as $key => $attachment) { @@ -669,7 +681,7 @@ class kolab_storage_dav_folder extends kolab_storage_folder return $result; } - protected function object_location($uid) + public function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } diff --git a/plugins/tasklist/README b/plugins/tasklist/README index 172a952c..29e07f27 100644 --- a/plugins/tasklist/README +++ b/plugins/tasklist/README @@ -1,7 +1,7 @@ A task management module for Roundcube -------------------------------------- -This plugin currently supports a local database as well as a Kolab groupware +This plugin currently supports a local database, CalDAV server or a Kolab groupware server as backends for tasklists and todo items storage. @@ -43,7 +43,7 @@ driver. $ cd ../../ $ bin/initdb.sh --dir=plugins/tasklist/drivers/database/SQL -4. Build css styles for the Elastic skin +4. Build css styles for the Elastic skin (if needed) $ lessc --relative-urls -x plugins/libkolab/skins/elastic/libkolab.less > plugins/libkolab/skins/elastic/libkolab.min.css diff --git a/plugins/tasklist/config.inc.php.dist b/plugins/tasklist/config.inc.php.dist index 399344cd..b04d96b7 100644 --- a/plugins/tasklist/config.inc.php.dist +++ b/plugins/tasklist/config.inc.php.dist @@ -1,8 +1,11 @@ + * + * Copyright (C) 2012-2022, Apheleia IT 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_caldav_driver extends tasklist_driver +{ + // features supported by the backend + public $alarms = false; + public $attachments = true; + public $attendees = true; + public $undelete = false; // task undelete action + public $alarm_types = ['DISPLAY','AUDIO']; + public $search_more_results; + + private $rc; + private $plugin; + private $storage; + private $lists; + private $folders = []; + private $tasks = []; + private $tags = []; + private $bonnie_api = false; + + + /** + * Default constructor + */ + public function __construct($plugin) + { + $this->rc = $plugin->rc; + $this->plugin = $plugin; + + // Initialize the CalDAV storage + $url = $this->rc->config->get('tasklist_caldav_server', 'http://localhost'); + $this->storage = new kolab_storage_dav($url); + + // get configuration for the Bonnie API + // $this->bonnie_api = libkolab::get_bonnie_api(); + + // $this->plugin->register_action('folder-acl', [$this, 'folder_acl']); + } + + /** + * Read available calendars for the current user and store them internally + */ + private function _read_lists($force = false) + { + // already read sources + if (isset($this->lists) && !$force) { + return $this->lists; + } + + // get all folders that have type "task" + $folders = $this->storage->get_folders('task'); + $this->lists = $this->folders = []; + + $prefs = $this->rc->config->get('kolab_tasklists', []); + + foreach ($folders as $folder) { + $tasklist = $this->folder_props($folder, $prefs); + + $this->lists[$tasklist['id']] = $tasklist; + $this->folders[$tasklist['id']] = $folder; +// $this->folders[$folder->name] = $folder; + } + + return $this->lists; + } + + /** + * Derive list properties from the given kolab_storage_folder object + */ + protected function folder_props($folder, $prefs) + { + if ($folder->get_namespace() == 'personal') { + $norename = false; + $editable = true; + $rights = 'lrswikxtea'; + $alarms = true; + } + else { + $alarms = false; + $rights = 'lr'; + $editable = false; + if ($myrights = $folder->get_myrights()) { + $rights = $myrights; + if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { + $editable = strpos($rights, 'i') !== false; + } + } + $info = $folder->get_folder_info(); + $norename = $readonly || $info['norename'] || $info['protected']; + } + + $list_id = $folder->id; + + return [ + 'id' => $list_id, + 'name' => $folder->get_name(), + 'listname' => $folder->get_foldername(), + 'editname' => $folder->get_foldername(), + 'color' => $folder->get_color('0000CC'), + 'showalarms' => isset($prefs[$list_id]['showalarms']) ? $prefs[$list_id]['showalarms'] : $alarms, + 'editable' => $editable, + 'rights' => $rights, + 'norename' => $norename, + 'active' => !isset($prefs[$list_id]['active']) || !empty($prefs[$list_id]['active']), + 'owner' => $folder->get_owner(), + 'parentfolder' => $folder->get_parent(), + 'default' => $folder->default, + 'virtual' => !empty($folder->virtual), + 'children' => true, // TODO: determine if that folder indeed has child folders + // 'subscribed' => (bool) $folder->is_subscribed(), + 'removable' => !$folder->default, + 'subtype' => $folder->subtype, + 'group' => $folder->default ? 'default' : $folder->get_namespace(), + 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), + 'caldavuid' => '', // $folder->get_uid(), + 'history' => !empty($this->bonnie_api), + ]; + } + + /** + * Get a list of available task lists from this source + * + * @param int Bitmask defining filter criterias. + * See FILTER_* constants for possible values. + */ + public function get_lists($filter = 0, &$tree = null) + { + $this->_read_lists(); + + $folders = $this->filter_folders($filter); + + $prefs = $this->rc->config->get('kolab_tasklists', []); + $lists = []; + + foreach ($folders as $folder) { + $parent_id = null; + $list_id = $folder->id; + $fullname = $folder->get_name(); + $listname = $folder->get_foldername(); + + // special handling for virtual folders + if ($folder instanceof kolab_storage_folder_user) { + $lists[$list_id] = [ + 'id' => $list_id, + 'name' => $fullname, + 'listname' => $listname, + 'title' => $folder->get_title(), + 'virtual' => true, + 'editable' => false, + 'rights' => 'l', + 'group' => 'other virtual', + 'class' => 'user', + 'parent' => $parent_id, + ]; + } + else if (!empty($folder->virtual)) { + $lists[$list_id] = [ + 'id' => $list_id, + 'name' => $fullname, + 'listname' => $listname, + 'virtual' => true, + 'editable' => false, + 'rights' => 'l', + 'group' => $folder->get_namespace(), + 'class' => 'folder', + 'parent' => $parent_id, + ]; + } + else { + if (empty($this->lists[$list_id])) { + $this->lists[$list_id] = $this->folder_props($folder, $prefs); + $this->folders[$list_id] = $folder; + } + + // $this->lists[$list_id]['parent'] = $parent_id; + $lists[$list_id] = $this->lists[$list_id]; + } + } + + return $lists; + } + + /** + * Get list of folders according to specified filters + * + * @param int Bitmask defining restrictions. See FILTER_* constants for possible values. + * + * @return array List of task folders + */ + protected function filter_folders($filter) + { + $this->_read_lists(); + + $folders = []; + foreach ($this->lists as $id => $list) { + if (!empty($this->folders[$id])) { + $folder = $this->folders[$id]; + + if ($folder->get_namespace() == 'personal') { + $folder->editable = true; + } + else if ($rights = $folder->get_myrights()) { + if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { + $folder->editable = strpos($rights, 'i') !== false; + } + } + + $folders[] = $folder; + } + } + + $plugin = $this->rc->plugins->exec_hook('tasklist_list_filter', [ + 'list' => $folders, + 'filter' => $filter, + 'tasklists' => $folders, + ]); + + if ($plugin['abort'] || !$filter) { + return $plugin['tasklists'] ?? []; + } + + $personal = $filter & self::FILTER_PERSONAL; + $shared = $filter & self::FILTER_SHARED; + + $tasklists = []; + foreach ($folders as $folder) { + if (($filter & self::FILTER_WRITEABLE) && !$folder->editable) { + continue; + } +/* + if (($filter & self::FILTER_INSERTABLE) && !$folder->insert) { + continue; + } + if (($filter & self::FILTER_ACTIVE) && !$folder->is_active()) { + continue; + } + if (($filter & self::FILTER_PRIVATE) && $folder->subtype != 'private') { + continue; + } + if (($filter & self::FILTER_CONFIDENTIAL) && $folder->subtype != 'confidential') { + continue; + } +*/ + if ($personal || $shared) { + $ns = $folder->get_namespace(); + if (!(($personal && $ns == 'personal') || ($shared && $ns == 'shared'))) { + continue; + } + } + + $tasklists[$folder->id] = $folder; + } + + return $tasklists; + } + + /** + * Get the kolab_calendar instance for the given calendar ID + * + * @param string List identifier (encoded imap folder name) + * + * @return ?kolab_storage_folder Object nor null if list doesn't exist + */ + protected function get_folder($id) + { + $this->_read_lists(); + + return $this->folders[$id] ?? null; + } + + + /** + * Create a new list assigned to the current user + * + * @param array Hash array with list properties + * name: List name + * color: The color of the list + * showalarms: True if alarms are enabled + * + * @return mixed ID of the new list on success, False on error + */ + public function create_list(&$prop) + { + $prop['type'] = 'task'; + + $id = $this->storage->folder_update($prop); + + if ($id === false) { + return false; + } + + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + + if (isset($prop['showalarms'])) { + $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; + } + + if (isset($prefs['kolab_tasklists'][$id])) { + $this->rc->user->save_prefs($prefs); + } + + // force page reload to properly render folder hierarchy + if (!empty($prop['parent'])) { + $prop['_reload'] = true; + } + else { + $folder = $this->get_folder($id); + $prop += $this->folder_props($folder, []); + } + + return $id; + } + + /** + * Update properties of an existing tasklist + * + * @param array Hash array with list properties + * id: List Identifier + * name: List name + * color: The color of the list + * showalarms: True if alarms are enabled (if supported) + * + * @return bool True on success, Fales on failure + */ + public function edit_list(&$prop) + { + if (!empty($prop['id'])) { + $id = $prop['id']; + $prop['type'] = 'task'; + + if ($this->storage->folder_update($prop) !== false) { + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + + if (isset($prop['showalarms'])) { + $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; + } + + if (isset($prefs['kolab_tasklists'][$id])) { + $this->rc->user->save_prefs($prefs); + } +/* + // force page reload if folder name/hierarchy changed + if ($newfolder != $prop['oldname']) { + $prop['_reload'] = true; + } +*/ + return true; + } + } + + return false; + } + + /** + * Set active/subscribed state of a list + * + * @param array Hash array with list properties + * id: List Identifier + * active: True if list is active, false if not + * permanent: True if list is to be subscribed permanently + * + * @return bool True on success, Fales on failure + */ + public function subscribe_list($prop) + { + if (!empty($prop['id'])) { + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + + if (isset($prop['permanent'])) { + $prefs['kolab_tasklists'][$prop['id']]['permanent'] = intval($prop['permanent']); + } + + if (isset($prop['active'])) { + $prefs['kolab_tasklists'][$prop['id']]['active'] = intval($prop['active']); + } + + $this->rc->user->save_prefs($prefs); + + return true; + } + + return false; + } + + /** + * Delete the given list with all its contents + * + * @param array Hash array with list properties + * id: list Identifier + * + * @return bool True on success, Fales on failure + */ + public function delete_list($prop) + { + if (!empty($prop['id'])) { + if ($this->storage->folder_delete($prop['id'], 'task')) { + // remove folder from user prefs + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + if (isset($prefs['kolab_tasklists'][$prop['id']])) { + unset($prefs['kolab_tasklists'][$prop['id']]); + $this->rc->user->save_prefs($prefs); + } + + return true; + } + } + + return false; + } + + /** + * Search for shared or otherwise not listed tasklists the user has access + * + * @param string Search string + * @param string Section/source to search + * + * @return array List of tasklists + */ + public function search_lists($query, $source) + { +/* + $this->search_more_results = false; + $this->lists = $this->folders = array(); + + // find unsubscribed IMAP folders that have "event" type + if ($source == 'folders') { + foreach ((array)kolab_storage::search_folders('task', $query, array('other')) as $folder) { + $this->folders[$folder->id] = $folder; + $this->lists[$folder->id] = $this->folder_props($folder, array()); + } + } + // search other user's namespace via LDAP + else if ($source == 'users') { + $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number + foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { + $folders = array(); + // search for tasks folders shared by this user + foreach (kolab_storage::list_user_folders($user, 'task', false) as $foldername) { + $folders[] = new kolab_storage_folder($foldername, 'task'); + } + + if (count($folders)) { + $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); + $this->folders[$userfolder->id] = $userfolder; + $this->lists[$userfolder->id] = $this->folder_props($userfolder, array()); + + foreach ($folders as $folder) { + $this->folders[$folder->id] = $folder; + $this->lists[$folder->id] = $this->folder_props($folder, array()); + $count++; + } + } + + if ($count >= $limit) { + $this->search_more_results = true; + break; + } + } + } + + return $this->get_lists(); +*/ + return []; + } + + /** + * Get a list of tags to assign tasks to + * + * @return array List of tags + */ + public function get_tags() + { + return []; + } + + /** + * Get number of tasks matching the given filter + * + * @param array List of lists to count tasks of + * + * @return array Hash array with counts grouped by status (all|flagged|completed|today|tomorrow|nodate) + */ + public function count_tasks($lists = null) + { + if (empty($lists)) { + $lists = $this->_read_lists(); + $lists = array_keys($lists); + } + else if (is_string($lists)) { + $lists = explode(',', $lists); + } + + $today_date = new DateTime('now', $this->plugin->timezone); + $today = $today_date->format('Y-m-d'); + $tomorrow_date = new DateTime('now + 1 day', $this->plugin->timezone); + $tomorrow = $tomorrow_date->format('Y-m-d'); + + $counts = ['all' => 0, 'today' => 0, 'tomorrow' => 0, 'later' => 0, 'overdue' => 0]; + + foreach ($lists as $list_id) { + if (!$folder = $this->get_folder($list_id)) { + continue; + } + + foreach ($folder->select([['tags', '!~', 'x-complete']], true) as $record) { + $rec = $this->_to_rcube_task($record, $list_id, false); + + if ($this->is_complete($rec)) { // don't count complete tasks + continue; + } + + $counts['all']++; + if (empty($rec['date'])) { + $counts['later']++; + } + else if ($rec['date'] == $today) { + $counts['today']++; + } + else if ($rec['date'] == $tomorrow) { + $counts['tomorrow']++; + } + else if ($rec['date'] < $today) { + $counts['overdue']++; + } + else if ($rec['date'] > $tomorrow) { + $counts['later']++; + } + } + } + + return $counts; + } + + /** + * Get all task records matching the given filter + * + * @param array Hash array with filter criterias: + * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) + * - from: Date range start as string (Y-m-d) + * - to: Date range end as string (Y-m-d) + * - search: Search query string + * - uid: Task UIDs + * @param array List of lists to get tasks from + * + * @return array List of tasks records matchin the criteria + */ + public function list_tasks($filter, $lists = null) + { + if (empty($lists)) { + $lists = $this->_read_lists(); + $lists = array_keys($lists); + } + else if (is_string($lists)) { + $lists = explode(',', $lists); + } + + $results = []; + + // query Kolab storage cache + $query = []; + if (isset($filter['mask']) && ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE)) { + $query[] = ['tags', '~', 'x-complete']; + } + else if (empty($filter['since'])) { + $query[] = ['tags', '!~', 'x-complete']; + } + + // full text search (only works with cache enabled) + if (!empty($filter['search'])) { + $search = mb_strtolower($filter['search']); + foreach (rcube_utils::normalize_string($search, true) as $word) { + $query[] = ['words', '~', $word]; + } + } + + if (!empty($filter['since'])) { + $query[] = ['changed', '>=', $filter['since']]; + } + + if (!empty($filter['uid'])) { + $query[] = ['uid', '=', (array) $filter['uid']]; + } + + foreach ($lists as $list_id) { + if (!$folder = $this->get_folder($list_id)) { + continue; + } + + foreach ($folder->select($query) as $record) { + // TODO: post-filter tasks returned from storage + $record['list_id'] = $list_id; + $results[] = $record; + } + } + + foreach (array_keys($results) as $idx) { + $results[$idx] = $this->_to_rcube_task($results[$idx], $results[$idx]['list_id']); + } + + return $results; + } + + /** + * Return data of a specific task + * + * @param mixed Hash array with task properties or task UID + * @param int Bitmask defining filter criterias for folders. + * See FILTER_* constants for possible values. + * + * @return array Hash array with task properties or false if not found + */ + public function get_task($prop, $filter = 0) + { + $this->_parse_id($prop); + + $id = $prop['uid']; + $list_id = $prop['list']; + $folders = $list_id ? [$list_id => $this->get_folder($list_id)] : $this->get_lists($filter); + + // find task in the available folders + foreach ($folders as $list_id => $folder) { + if (is_array($folder)) { + $folder = $this->folders[$list_id]; + } + if (is_numeric($list_id) || !$folder) { + continue; + } + if (empty($this->tasks[$id]) && ($object = $folder->get_object($id))) { + $this->tasks[$id] = $this->_to_rcube_task($object, $list_id); + break; + } + } + + return $this->tasks[$id] ?? false; + } + + /** + * Get all decendents of the given task record + * + * @param mixed Hash array with task properties or task UID + * @param bool True if all childrens children should be fetched + * + * @return array List of all child task IDs + */ + public function get_childs($prop, $recursive = false) + { + if (is_string($prop)) { + $task = $this->get_task($prop); + $prop = ['uid' => $task['uid'], 'list' => $task['list']]; + } + else { + $this->_parse_id($prop); + } + + $childs = []; + $list_id = $prop['list']; + $task_ids = [$prop['uid']]; + $folder = $this->get_folder($list_id); + + // query for childs (recursively) + while ($folder && !empty($task_ids)) { + $query_ids = []; + foreach ($task_ids as $task_id) { + $query = [['tags','=','x-parent:' . $task_id]]; + foreach ($folder->select($query) as $record) { + // don't rely on kolab_storage_folder filtering + if ($record['parent_id'] == $task_id) { + $childs[] = $list_id . ':' . $record['uid']; + $query_ids[] = $record['uid']; + } + } + } + + if (!$recursive) { + break; + } + + $task_ids = $query_ids; + } + + return $childs; + } + + /** + * Provide a list of revisions for the given task + * + * @param array $task Hash array with task properties + * + * @return array List of changes, each as a hash array + * @see tasklist_driver::get_task_changelog() + */ + public function get_task_changelog($prop) + { + if (empty($this->bonnie_api)) { + return false; + } +/* + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + $result = $uid && $mailbox ? $this->bonnie_api->changelog('task', $uid, $mailbox, $msguid) : null; + if (is_array($result) && $result['uid'] == $uid) { + return $result['changes']; + } +*/ + return false; + } + + /** + * Return full data of a specific revision of an event + * + * @param mixed $task UID string or hash array with task properties + * @param mixed $rev Revision number + * + * @return array Task object as hash array + * @see tasklist_driver::get_task_revision() + */ + public function get_task_revison($prop, $rev) + { + if (empty($this->bonnie_api)) { + return false; + } +/* + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + // call Bonnie API + $result = $this->bonnie_api->get('task', $uid, $rev, $mailbox, $msguid); + if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) { + $format = kolab_format::factory('task'); + $format->load($result['xml']); + $rec = $format->to_array(); + $format->get_attachments($rec, true); + + if ($format->is_valid()) { + $rec = self::_to_rcube_task($rec, $list_id, false); + $rec['rev'] = $result['rev']; + return $rec; + } + } +*/ + return false; + } + + /** + * Command the backend to restore a certain revision of a task. + * This shall replace the current object with an older version. + * + * @param mixed $task UID string or hash array with task properties + * @param mixed $rev Revision number + * + * @return bool True on success, False on failure + * @see tasklist_driver::restore_task_revision() + */ + public function restore_task_revision($prop, $rev) + { + if (empty($this->bonnie_api)) { + return false; + } +/* + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + $folder = $this->get_folder($list_id); + $success = false; + + if ($folder && ($raw_msg = $this->bonnie_api->rawdata('task', $uid, $rev, $mailbox))) { + $imap = $this->rc->get_storage(); + + // insert $raw_msg as new message + if ($imap->save_message($folder->name, $raw_msg, null, false)) { + $success = true; + + // delete old revision from imap and cache + $imap->delete_message($msguid, $folder->name); + $folder->cache->set($msguid, false); + } + } + + return $success; +*/ + } + + /** + * Get a list of property changes beteen two revisions of a task object + * + * @param array $task Hash array with task properties + * @param mixed $rev Revisions: "from:to" + * + * @return array List of property changes, each as a hash array + * @see tasklist_driver::get_task_diff() + */ + public function get_task_diff($prop, $rev1, $rev2) + { +/* + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($prop); + + // call Bonnie API + $result = $this->bonnie_api->diff('task', $uid, $rev1, $rev2, $mailbox, $msguid, $instance_id); + if (is_array($result) && $result['uid'] == $uid) { + $result['rev1'] = $rev1; + $result['rev2'] = $rev2; + + $keymap = array( + 'start' => 'start', + 'due' => 'date', + 'dstamp' => 'changed', + 'summary' => 'title', + 'alarm' => 'alarms', + 'attendee' => 'attendees', + 'attach' => 'attachments', + 'rrule' => 'recurrence', + 'related-to' => 'parent_id', + 'percent-complete' => 'complete', + 'lastmodified-date' => 'changed', + ); + $prop_keymaps = array( + 'attachments' => array('fmttype' => 'mimetype', 'label' => 'name'), + 'attendees' => array('partstat' => 'status'), + ); + $special_changes = array(); + + // map kolab event properties to keys the client expects + array_walk($result['changes'], function(&$change, $i) use ($keymap, $prop_keymaps, $special_changes) { + if (array_key_exists($change['property'], $keymap)) { + $change['property'] = $keymap[$change['property']]; + } + if ($change['property'] == 'priority') { + $change['property'] = 'flagged'; + $change['old'] = $change['old'] == 1 ? $this->plugin->gettext('yes') : null; + $change['new'] = $change['new'] == 1 ? $this->plugin->gettext('yes') : null; + } + // map alarms trigger value + if ($change['property'] == 'alarms') { + if (is_array($change['old']) && is_array($change['old']['trigger'])) + $change['old']['trigger'] = $change['old']['trigger']['value']; + if (is_array($change['new']) && is_array($change['new']['trigger'])) + $change['new']['trigger'] = $change['new']['trigger']['value']; + } + // make all property keys uppercase + if ($change['property'] == 'recurrence') { + $special_changes['recurrence'] = $i; + foreach (array('old','new') as $m) { + if (is_array($change[$m])) { + $props = array(); + foreach ($change[$m] as $k => $v) { + $props[strtoupper($k)] = $v; + } + $change[$m] = $props; + } + } + } + // map property keys names + if (is_array($prop_keymaps[$change['property']])) { + foreach ($prop_keymaps[$change['property']] as $k => $dest) { + if (is_array($change['old']) && array_key_exists($k, $change['old'])) { + $change['old'][$dest] = $change['old'][$k]; + unset($change['old'][$k]); + } + if (is_array($change['new']) && array_key_exists($k, $change['new'])) { + $change['new'][$dest] = $change['new'][$k]; + unset($change['new'][$k]); + } + } + } + + if ($change['property'] == 'exdate') { + $special_changes['exdate'] = $i; + } + else if ($change['property'] == 'rdate') { + $special_changes['rdate'] = $i; + } + }); + + // merge some recurrence changes + foreach (array('exdate','rdate') as $prop) { + if (array_key_exists($prop, $special_changes)) { + $exdate = $result['changes'][$special_changes[$prop]]; + if (array_key_exists('recurrence', $special_changes)) { + $recurrence = &$result['changes'][$special_changes['recurrence']]; + } + else { + $i = count($result['changes']); + $result['changes'][$i] = array('property' => 'recurrence', 'old' => array(), 'new' => array()); + $recurrence = &$result['changes'][$i]['recurrence']; + } + $key = strtoupper($prop); + $recurrence['old'][$key] = $exdate['old']; + $recurrence['new'][$key] = $exdate['new']; + unset($result['changes'][$special_changes[$prop]]); + } + } + + return $result; + } +*/ + return false; + } + + /** + * Helper method to resolved the given task identifier into uid and folder + * + * @return array (uid,folder,msguid) tuple + */ + private function _resolve_task_identity($prop) + { +/* + $mailbox = $msguid = null; + + $this->_parse_id($prop); + $uid = $prop['uid']; + $list_id = $prop['list']; + + if ($folder = $this->get_folder($list_id)) { + $mailbox = $folder->get_mailbox_id(); + + // get task object from storage in order to get the real object uid an msguid + if ($rec = $folder->get_object($uid)) { + $msguid = $rec['_msguid']; + $uid = $rec['uid']; + } + } + + return array($uid, $mailbox, $msguid); +*/ + } + + /** + * Get a list of pending alarms to be displayed to the user + * + * @param int $time Current time (unix timestamp) + * @param mixed $lists List of list IDs to show alarms for (either as array or comma-separated string) + * + * @return array A list of alarms, each encoded as hash array with task properties + * @see tasklist_driver::pending_alarms() + */ + public function pending_alarms($time, $lists = null) + { + $interval = 300; + $time -= $time % 60; + + $slot = $time; + $slot -= $slot % $interval; + + $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); + $last -= $last % $interval; + + // only check for alerts once in 5 minutes + if ($last == $slot) { + return []; + } + + if ($lists && is_string($lists)) { + $lists = explode(',', $lists); + } + + $time = $slot + $interval; + + $candidates = []; + $query = [ + ['tags', '=', 'x-has-alarms'], + ['tags', '!=', 'x-complete'], + ]; + + $this->_read_lists(); + + foreach ($this->lists as $lid => $list) { + // skip lists with alarms disabled + if (empty($list['showalarms']) || ($lists && !in_array($lid, $lists))) { + continue; + } + + $folder = $this->get_folder($lid); + + foreach ($folder->select($query) as $record) { + if ((empty($record['valarms']) && empty($record['alarms'])) + || $record['status'] == 'COMPLETED' + || $record['complete'] == 100 + ) { + // don't trust the query :-) + continue; + } + + $task = $this->_to_rcube_task($record, $lid, false); + + // add to list if alarm is set + $alarm = libcalendaring::get_next_alarm($task, 'task'); + if ($alarm && !empty($alarm['time']) && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) { + $id = $alarm['id']; // use alarm-id as primary identifier + $candidates[$id] = [ + 'id' => $id, + 'title' => $task['title'] ?? null, + 'date' => $task['date'] ?? null, + 'time' => $task['time'], + 'notifyat' => $alarm['time'], + 'action' => $alarm['action'] ?? null, + ]; + } + } + } + + // get alarm information stored in local database + if (!empty($candidates)) { + $alarm_ids = array_map([$this->rc->db, 'quote'], array_keys($candidates)); + $result = $this->rc->db->query("SELECT *" + . " FROM " . $this->rc->db->table_name('kolab_alarms', true) + . " WHERE `alarm_id` IN (" . join(',', $alarm_ids) . ")" + . " AND `user_id` = ?", + $this->rc->user->ID + ); + + while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { + $dbdata[$rec['alarm_id']] = $rec; + } + } + + $alarms = []; + foreach ($candidates as $id => $task) { + // skip dismissed + if (!empty($dbdata[$id]['dismissed'])) { + continue; + } + + // snooze function may have shifted alarm time + $notifyat = !empty($dbdata[$id]['notifyat']) ? strtotime($dbdata[$id]['notifyat']) : $task['notifyat']; + if ($notifyat <= $time) { + $alarms[] = $task; + } + } + + return $alarms; + } + + /** + * (User) feedback after showing an alarm notification + * This should mark the alarm as 'shown' or snooze it for the given amount of time + * + * @param string $id Task identifier + * @param int $snooze Suspend the alarm for this number of seconds + */ + public function dismiss_alarm($id, $snooze = 0) + { + // delete old alarm entry + $this->rc->db->query( + "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " + WHERE `alarm_id` = ? AND `user_id` = ?", + $id, + $this->rc->user->ID + ); + + // set new notifyat time or unset if not snoozed + $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; + + $query = $this->rc->db->query( + "INSERT INTO " . $this->rc->db->table_name('kolab_alarms', true) . " + (`alarm_id`, `user_id`, `dismissed`, `notifyat`) + VALUES (?, ?, ?, ?)", + $id, + $this->rc->user->ID, + $snooze > 0 ? 0 : 1, + $notifyat + ); + + return $this->rc->db->affected_rows($query); + } + + /** + * Remove alarm dismissal or snooze state + * + * @param string $id Task identifier + */ + public function clear_alarms($id) + { + // delete alarm entry + $this->rc->db->query( + "DELETE FROM " . $this->rc->db->table_name('kolab_alarms', true) . " + WHERE `alarm_id` = ? AND `user_id` = ?", + $id, + $this->rc->user->ID + ); + + return true; + } + + /** + * Extract uid + list identifiers from the given input + * + * @param mixed array or string with task identifier(s) + */ + private function _parse_id(&$prop) + { + $id = null; + if (is_array($prop)) { + // 'uid' + 'list' available, nothing to be done + if (!empty($prop['uid']) && !empty($prop['list'])) { + return; + } + + // 'id' is given + if (!empty($prop['id'])) { + if (!empty($prop['list'])) { + $list_id = !empty($prop['_fromlist']) ? $prop['_fromlist'] : $prop['list']; + if (strpos($prop['id'], $list_id.':') === 0) { + $prop['uid'] = substr($prop['id'], strlen($list_id)+1); + } + else { + $prop['uid'] = $prop['id']; + } + } + else { + $id = $prop['id']; + } + } + } + else { + $id = strval($prop); + $prop = []; + } + + // split 'id' into list + uid + if (!empty($id)) { + if (strpos($id, ':')) { + list($list, $uid) = explode(':', $id, 2); + $prop['uid'] = $uid; + $prop['list'] = $list; + } + else { + $prop['uid'] = $id; + } + } + } + + /** + * Convert from Kolab_Format to internal representation + */ + private function _to_rcube_task($record, $list_id, $all = true) + { + $id_prefix = $list_id . ':'; + $task = [ + 'id' => $id_prefix . $record['uid'], + 'uid' => $record['uid'], + 'title' => $record['title'] ?? '', +// 'location' => $record['location'], + 'description' => $record['description'] ?? '', + 'flagged' => !empty($record['priority']) && $record['priority'] == 1, + 'complete' => floatval(($record['complete'] ?? 0) / 100), + 'status' => $record['status'] ?? null, + 'parent_id' => !empty($record['parent_id']) ? $id_prefix . $record['parent_id'] : null, + 'recurrence' => $record['recurrence'] ?? [], + 'attendees' => $record['attendees'] ?? [], + 'organizer' => $record['organizer'] ?? null, + 'sequence' => $record['sequence'] ?? null, + 'list' => $list_id, + 'links' => [], // $record['links'], + 'tags' => [], // $record['tags'], + ]; + + // convert from DateTime to internal date format + if (isset($record['due']) && $record['due'] instanceof DateTimeInterface) { + $due = $this->plugin->lib->adjust_timezone($record['due']); + $task['date'] = $due->format('Y-m-d'); + if (empty($record['due']->_dateonly)) { + $task['time'] = $due->format('H:i'); + } + } + // convert from DateTime to internal date format + if (isset($record['start']) && $record['start'] instanceof DateTimeInterface) { + $start = $this->plugin->lib->adjust_timezone($record['start']); + $task['startdate'] = $start->format('Y-m-d'); + if (empty($record['start']->_dateonly)) { + $task['starttime'] = $start->format('H:i'); + } + } + if (isset($record['changed']) && $record['changed'] instanceof DateTimeInterface) { + $task['changed'] = $record['changed']; + } + if (isset($record['created']) && $record['created'] instanceof DateTimeInterface) { + $task['created'] = $record['created']; + } + + if (isset($record['valarms'])) { + $task['valarms'] = $record['valarms']; + } + else if (isset($record['alarms'])) { + $task['alarms'] = $record['alarms']; + } + + if (!empty($task['attendees'])) { + foreach ((array) $task['attendees'] as $i => $attendee) { + if (isset($attendee['delegated-from']) && is_array($attendee['delegated-from'])) { + $task['attendees'][$i]['delegated-from'] = join(', ', $attendee['delegated-from']); + } + if (isset($attendee['delegated-to']) && is_array($attendee['delegated-to'])) { + $task['attendees'][$i]['delegated-to'] = join(', ', $attendee['delegated-to']); + } + } + } + + if (!empty($record['_attachments'])) { + foreach ($record['_attachments'] as $key => $attachment) { + if ($attachment !== false) { + if (empty($attachment['name'])) { + $attachment['name'] = $key; + } + $attachments[] = $attachment; + } + } + + $task['attachments'] = $attachments; + } + + return $task; + } + + /** + * Convert the given task record into a data structure that can be passed to kolab_storage backend for saving + * (opposite of self::_to_rcube_event()) + */ + private function _from_rcube_task($task, $old = []) + { + $object = $task; + $id_prefix = $task['list'] . ':'; + + $toDT = function($date) { + // Convert DateTime into libcalendaring_datetime + return libcalendaring_datetime::createFromFormat( + 'Y-m-d\\TH:i:s', + $date->format('Y-m-d\\TH:i:s'), + $date->getTimezone() + ); + }; + + if (!empty($task['date'])) { + $object['due'] = $toDT(rcube_utils::anytodatetime($task['date'] . ' ' . ($task['time'] ?? ''), $this->plugin->timezone)); + if (empty($task['time'])) { + $object['due']->_dateonly = true; + } + unset($object['date']); + } + + if (!empty($task['startdate'])) { + $object['start'] = $toDT(rcube_utils::anytodatetime($task['startdate'] . ' ' . ($task['starttime'] ?? ''), $this->plugin->timezone)); + if (empty($task['starttime'])) { + $object['start']->_dateonly = true; + } + unset($object['startdate']); + } + + // as per RFC (and the Kolab schema validation), start and due dates need to be of the same type (#3614) + // this should be catched in the client already but just make sure we don't write invalid objects + if (!empty($object['start']) && !empty($object['due']) && $object['due']->_dateonly != $object['start']->_dateonly) { + $object['start']->_dateonly = true; + $object['due']->_dateonly = true; + } + + $object['complete'] = $task['complete'] * 100; + if ($task['complete'] == 1.0 && empty($task['complete'])) { + $object['status'] = 'COMPLETED'; + } + + if (!empty($task['flagged'])) { + $object['priority'] = 1; + } + else { + $object['priority'] = isset($old['priority']) && $old['priority'] > 1 ? $old['priority'] : 0; + } + + // remove list: prefix from parent_id + if (!empty($task['parent_id']) && strpos($task['parent_id'], $id_prefix) === 0) { + $object['parent_id'] = substr($task['parent_id'], strlen($id_prefix)); + } + + // copy meta data (starting with _) from old object + foreach ((array) $old as $key => $val) { + if (!isset($object[$key]) && $key[0] == '_') { + $object[$key] = $val; + } + } + + // copy recurrence rules if the client didn't submit it (#2713) + if (!array_key_exists('recurrence', $object) && !empty($old['recurrence'])) { + $object['recurrence'] = $old['recurrence']; + } + + unset($task['attachments']); + kolab_format::merge_attachments($object, $old); + + // allow sequence increments if I'm the organizer + if ($this->plugin->is_organizer($object) && empty($object['_method'])) { + unset($object['sequence']); + } + else if (isset($old['sequence']) && empty($object['_method'])) { + $object['sequence'] = $old['sequence']; + } + + unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']); + + return $object; + } + + /** + * Add a single task to the database + * + * @param array Hash array with task properties (see header of tasklist_driver.php) + * + * @return mixed New task ID on success, False on error + */ + public function create_task($task) + { + return $this->edit_task($task); + } + + /** + * Update a task entry with the given data + * + * @param array Hash array with task properties (see header of tasklist_driver.php) + * + * @return bool True on success, False on error + */ + public function edit_task($task) + { + $this->_parse_id($task); + + if (empty($task['list']) || !($folder = $this->get_folder($task['list']))) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Invalid list identifer to save task: " . print_r($task['list'], true) + ], + true, false); + + return false; + } + + // moved from another folder + if (!empty($task['_fromlist']) && ($fromfolder = $this->get_folder($task['_fromlist']))) { + if (!$fromfolder->move($task['uid'], $folder)) { + return false; + } + + unset($task['_fromlist']); + } + + // load previous version of this task to merge + if (!empty($task['id'])) { + $old = $folder->get_object($task['uid']); + if (!$old) { + return false; + } + + // merge existing properties if the update isn't complete + if (!isset($task['title']) || !isset($task['complete'])) { + $task += $this->_to_rcube_task($old, $task['list']); + } + } + + // generate new task object from RC input + $object = $this->_from_rcube_task($task, $old ?? null); + + $object['created'] = $old['created'] ?? null; + + $saved = $folder->save($object, 'task', !empty($old) ? $task['uid'] : null); + + if (!$saved) { + rcube::raise_error([ + 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving task object to Kolab server" + ], + true, false); + + return false; + } + + $task = $this->_to_rcube_task($object, $task['list']); + $this->tasks[$task['uid']] = $task; + + return true; + } + + /** + * Move a single task to another list + * + * @param array $task Hash array with task properties + * + * @return bool True on success, False on error + * @see tasklist_driver::move_task() + */ + public function move_task($task) + { + $this->_parse_id($task); + + if (empty($task['list']) || !($folder = $this->get_folder($task['list']))) { + return false; + } + + // execute move command + if (!empty($task['_fromlist']) && ($fromfolder = $this->get_folder($task['_fromlist']))) { + return $fromfolder->move($task['uid'], $folder); + } + + return false; + } + + /** + * Remove a single task from the database + * + * @param array Hash array with task properties: + * id: Task identifier + * @param bool Remove record irreversible (mark as deleted otherwise, if supported by the backend) + * + * @return bool True on success, False on error + */ + public function delete_task($task, $force = true) + { + $this->_parse_id($task); + + if (empty($task['list']) || !($folder = $this->get_folder($task['list']))) { + return false; + } + + $status = $folder->delete($task['uid'], $force); + + return $status; + } + + /** + * Restores a single deleted task (if supported) + * + * @param array Hash array with task properties: + * id: Task identifier + * @return boolean True on success, False on error + */ + public function undelete_task($prop) + { + // TODO: implement this + return false; + } + + + /** + * Get attachment properties + * + * @param string $id Attachment identifier + * @param array $task Hash array with event properties: + * id: Task identifier + * list: List identifier + * rev: Revision (optional) + * + * @return array Hash array with attachment properties: + * id: Attachment identifier + * name: Attachment name + * mimetype: MIME content type of the attachment + * size: Attachment size + */ + public function get_attachment($id, $task) + { + // get old revision of the object + if (!empty($task['rev'])) { + $task = $this->get_task_revison($task, $task['rev']); + } + else { + $task = $this->get_task($task); + } + + if ($task && !empty($task['attachments'])) { + foreach ($task['attachments'] as $att) { + if ($att['id'] == $id) { + if (!empty($att['data'])) { + // This way we don't have to call get_attachment_body() again + $att['body'] = &$att['data']; + } + + return $att; + } + } + } + + return null; + } + + /** + * Get attachment body + * + * @param string $id Attachment identifier + * @param array $task Hash array with event properties: + * id: Task identifier + * list: List identifier + * rev: Revision (optional) + * + * @return string Attachment body + */ + public function get_attachment_body($id, $task) + { + $this->_parse_id($task); +/* + // get old revision of event + if ($task['rev']) { + if (empty($this->bonnie_api)) { + return false; + } + + $cid = substr($id, 4); + + // call Bonnie API and get the raw mime message + list($uid, $mailbox, $msguid) = $this->_resolve_task_identity($task); + if ($msg_raw = $this->bonnie_api->rawdata('task', $uid, $task['rev'], $mailbox, $msguid)) { + // parse the message and find the part with the matching content-id + $message = rcube_mime::parse_message($msg_raw); + foreach ((array)$message->parts as $part) { + if ($part->headers['content-id'] && trim($part->headers['content-id'], '<>') == $cid) { + return $part->body; + } + } + } + + return false; + } +*/ + + if ($storage = $this->get_folder($task['list'])) { + return $storage->get_attachment($id, $task); + } + + return false; + } + + /** + * Build a struct representing the given message reference + * + * @see tasklist_driver::get_message_reference() + */ + public function get_message_reference($uri_or_headers, $folder = null) + { +/* + if (is_object($uri_or_headers)) { + $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); + } + + if (is_string($uri_or_headers)) { + return kolab_storage_config::get_message_reference($uri_or_headers, 'task'); + } +*/ + return false; + } + + /** + * Find tasks assigned to a specified message + * + * @see tasklist_driver::get_message_related_tasks() + */ + public function get_message_related_tasks($headers, $folder) + { + return []; +/* + $config = kolab_storage_config::get_instance(); + $result = $config->get_message_relations($headers, $folder, 'task'); + + foreach ($result as $idx => $rec) { + $result[$idx] = $this->_to_rcube_task($rec, kolab_storage::folder_id($rec['_mailbox'])); + } + + return $result; +*/ + } + + /** + * + */ + public function tasklist_edit_form($action, $list, $fieldprop) + { + $this->_read_lists(); + + if (!empty($list['id']) && ($list = $this->lists[$list['id']])) { + $folder_name = $this->get_folder($list['id'])->name; + } + else { + $folder_name = ''; + } + + $hidden_fields[] = ['name' => 'oldname', 'value' => $folder_name]; + + // folder name (default field) + $input_name = new html_inputfield(['name' => 'name', 'id' => 'taskedit-tasklistname', 'size' => 20]); + $fieldprop['name']['value'] = $input_name->show($list['editname'] ?? ''); + + // General tab + $form = [ + 'properties' => [ + 'name' => $this->rc->gettext('properties'), + 'fields' => [], + ] + ]; + + foreach (['name', 'showalarms'] as $f) { + $form['properties']['fields'][$f] = $fieldprop[$f]; + } + + return kolab_utils::folder_form($form, $folder_name, 'tasklist', $hidden_fields, true); + } +} diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php index 11c8578e..e10891ff 100644 --- a/plugins/tasklist/drivers/tasklist_driver.php +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -75,7 +75,7 @@ abstract class tasklist_driver public $attendees = false; public $undelete = false; // task undelete action public $sortable = false; - public $alarm_types = array('DISPLAY'); + public $alarm_types = ['DISPLAY']; public $alarm_absolute = true; public $last_error; @@ -331,7 +331,7 @@ abstract class tasklist_driver public function get_message_related_tasks($headers, $folder) { // to be implemented by the derived classes - return array(); + return []; } /** @@ -342,7 +342,8 @@ abstract class tasklist_driver */ public function is_complete($task) { - return ($task['complete'] >= 1.0 && empty($task['status'])) || $task['status'] === 'COMPLETED'; + return (isset($task['complete']) && $task['complete'] >= 1.0 && empty($task['status'])) + || (!empty($task['status']) && $task['status'] === 'COMPLETED'); } /** @@ -428,7 +429,7 @@ abstract class tasklist_driver */ public function tasklist_edit_form($action, $list, $formfields) { - $table = new html_table(array('cols' => 2, 'class' => 'propform')); + $table = new html_table(['cols' => 2, 'class' => 'propform']); foreach ($formfields as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $rcmail->gettext("$domain.$col"); @@ -446,13 +447,13 @@ abstract class tasklist_driver public function tasklist_caldav_url($list) { $rcmail = rcube::get_instance(); - if (!empty($list['caldavuid']) && ($template = $rcmail->config->get('calendar_caldav_url', null))) { - return strtr($template, array( + if (!empty($list['caldavuid']) && ($template = $rcmail->config->get('calendar_caldav_url'))) { + return strtr($template, [ '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), '%i' => urlencode($list['caldavuid']), '%n' => urlencode($list['editname']), - )); + ]); } return null; diff --git a/plugins/tasklist/skins/elastic/templates/taskedit.html b/plugins/tasklist/skins/elastic/templates/taskedit.html index d36b30e5..2619d703 100644 --- a/plugins/tasklist/skins/elastic/templates/taskedit.html +++ b/plugins/tasklist/skins/elastic/templates/taskedit.html @@ -99,7 +99,9 @@
- + + +

. */ +#[AllowDynamicProperties] class tasklist extends rcube_plugin { const FILTER_MASK_TODAY = 1; @@ -206,7 +207,8 @@ class tasklist extends rcube_plugin $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; + $success = $got_msg = false; + $refresh = []; // force notify if hidden + active $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); @@ -284,8 +286,7 @@ class tasklist extends rcube_plugin foreach ((array)$rec['id'] as $id) { $r = $rec; $r['id'] = $id; - if ($this->driver->move_task($r)) { - $new_task = $this->driver->get_task($r); + if ($this->driver->move_task($r) && ($new_task = $this->driver->get_task($r))) { $new_task['tempid'] = $id; $refresh[] = $new_task; $success = true; @@ -330,7 +331,7 @@ class tasklist extends rcube_plugin // 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()) { + if ($parent = $this->driver->get_task($parent)) { $refresh[] = $parent; } } @@ -547,23 +548,26 @@ class tasklist extends rcube_plugin $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)) + 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 + } + else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); + } } } // unlock client $this->rc->output->command('plugin.unlock_saving', $success); - if ($refresh) { + if (!empty($refresh)) { if (!empty($refresh['id'])) { $this->encode_task($refresh); } else if (is_array($refresh)) { - foreach ($refresh as $i => $r) + foreach ($refresh as $i => $r) { $this->encode_task($refresh[$i]); + } } $this->rc->output->command('plugin.update_task', $refresh); } @@ -688,13 +692,13 @@ class tasklist extends rcube_plugin } // convert the submitted recurrence settings - if (is_array($rec['recurrence'])) { + if (isset($rec['recurrence']) && is_array($rec['recurrence'])) { $refdate = null; if (!empty($rec['date'])) { - $refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); + $refdate = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone); } else if (!empty($rec['startdate'])) { - $refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); + $refdate = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone); } if ($refdate) { @@ -732,7 +736,7 @@ class tasklist extends rcube_plugin if (!empty($rec['attendees'])) { foreach ((array) $rec['attendees'] as $i => $attendee) { - if (is_string($attendee['rsvp'])) { + if (isset($attendee['rsvp']) && is_string($attendee['rsvp'])) { $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; } } @@ -768,16 +772,17 @@ class tasklist extends rcube_plugin 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); + $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); + $date = new DateTime(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? ''), $this->timezone); } $rec[$date_key] = $date->format('Y-m-d'); - if (!empty($rec[$time_key])) + if (!empty($rec[$time_key])) { $rec[$time_key] = $date->format('H:i'); + } return true; } @@ -804,7 +809,7 @@ class tasklist extends rcube_plugin private function handle_recurrence(&$rec, $old) { $clone = null; - if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && is_array($rec['recurrence'])) { + if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && !empty($rec['recurrence'])) { $engine = libcalendaring::get_recurrence(); $rrule = $rec['recurrence']; $updates = array(); @@ -814,12 +819,13 @@ class tasklist extends rcube_plugin if (empty($rec[$date_key])) continue; - $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); + $date = new DateTime($rec[$date_key] . ' ' . ($rec[$time_key] ?? ''), $this->timezone); $engine->init($rrule, $date); if ($next = $engine->next_start()) { $updates[$date_key] = $next->format('Y-m-d'); - if (!empty($rec[$time_key])) + if (!empty($rec[$time_key])) { $updates[$time_key] = $next->format('H:i'); + } } } @@ -1174,15 +1180,15 @@ class tasklist extends rcube_plugin */ private function encode_task(&$rec) { - $rec['mask'] = $this->filter_mask($rec); - $rec['flagged'] = intval($rec['flagged']); - $rec['complete'] = floatval($rec['complete']); + $rec['mask'] = $this->filter_mask($rec); + $rec['flagged'] = intval($rec['flagged'] ?? 0); + $rec['complete'] = floatval($rec['complete'] ?? 0); - if (is_object($rec['created'])) { + if (!empty($rec['created']) && is_object($rec['created'])) { $rec['created_'] = $this->rc->format_date($rec['created']); $rec['created'] = $rec['created']->format('U'); } - if (is_object($rec['changed'])) { + if (!empty($rec['changed']) && is_object($rec['changed'])) { $rec['changed_'] = $this->rc->format_date($rec['changed']); $rec['changed'] = $rec['changed']->format('U'); } @@ -1190,9 +1196,9 @@ class tasklist extends rcube_plugin $rec['changed'] = null; } - if ($rec['date']) { + if (!empty($rec['date'])) { try { - $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); + $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; @@ -1206,9 +1212,9 @@ class tasklist extends rcube_plugin $rec['_hasdate'] = 0; } - if ($rec['startdate']) { + if (!empty($rec['startdate'])) { try { - $date = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); + $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')); } @@ -1224,12 +1230,13 @@ class tasklist extends rcube_plugin if (!empty($rec['recurrence'])) { $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']); - $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], $rec['time'] || $rec['starttime']); + $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], !empty($rec['time']) || !empty($rec['starttime'])); } if (!empty($rec['attachments'])) { foreach ((array) $rec['attachments'] as $k => $attachment) { $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); + unset($rec['attachments'][$k]['data']); } } @@ -1248,17 +1255,21 @@ class tasklist extends rcube_plugin $rec['description'] = $h2t->get_text(); } - if (!is_array($rec['tags'])) - $rec['tags'] = (array)$rec['tags']; + if (!isset($rec['tags']) || !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 (in_array($rec['id'], $this->collapsed_tasks)) { + $rec['collapsed'] = true; + } - if (empty($rec['parent_id'])) + if (empty($rec['parent_id'])) { $rec['parent_id'] = null; + } - $this->task_titles[$rec['id']] = $rec['title']; + $this->task_titles[$rec['id']] = $rec['title'] ?? ''; } /** @@ -1267,7 +1278,9 @@ class tasklist extends rcube_plugin 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); + return isset($task['description']) + && preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) + && strpos($task['description'], '') > 0; } /** @@ -1303,9 +1316,9 @@ class tasklist extends rcube_plugin static $today, $today_date, $tomorrow, $weeklimit; if (!$today) { - $today_date = new DateTime('now', $this->timezone); + $today_date = new libcalendaring_datetime('now', $this->timezone); $today = $today_date->format('Y-m-d'); - $tomorrow_date = new DateTime('now + 1 day', $this->timezone); + $tomorrow_date = new libcalendaring_datetime('now + 1 day', $this->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); // In Kolab-mode we hide "Next 7 days" filter, which means @@ -1320,21 +1333,25 @@ class tasklist extends rcube_plugin } $mask = 0; - $start = $rec['startdate'] ?: '1900-00-00'; - $duedate = $rec['date'] ?: '3000-00-00'; + $start = !empty($rec['startdate']) ? $rec['startdate'] : '1900-00-00'; + $duedate = !empty($rec['date']) ? $rec['date'] : '3000-00-00'; - if ($rec['flagged']) + if (!empty($rec['flagged'])) { $mask |= self::FILTER_MASK_FLAGGED; - if ($this->driver->is_complete($rec)) + } + if ($this->driver->is_complete($rec)) { $mask |= self::FILTER_MASK_COMPLETE; + } - if (empty($rec['date'])) + if (empty($rec['date'])) { $mask |= self::FILTER_MASK_NODATE; - else if ($rec['date'] < $today) + } + 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)) + if ($duedate <= $today || (!empty($rec['startdate']) && $start <= $today)) $mask |= self::FILTER_MASK_TODAY; else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow)) $mask |= self::FILTER_MASK_TOMORROW; @@ -1343,8 +1360,8 @@ class tasklist extends rcube_plugin 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); + else if (!empty($rec['startdate']) || !empty($rec['date'])) { + $date = new libcalendaring_datetime(!empty($rec['startdate']) ? $rec['startdate'] : $rec['date'], $this->timezone); // set safe recurrence start while ($date->format('Y-m-d') >= $today) { @@ -1392,10 +1409,12 @@ class tasklist extends rcube_plugin } // add masks for assigned tasks - if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) + 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) + } + else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) { $mask |= self::FILTER_MASK_MYTASKS; + } return $mask; } @@ -1406,7 +1425,7 @@ class tasklist extends rcube_plugin public function is_attendee($task) { $emails = $this->lib->get_user_emails(); - foreach ((array)$task['attendees'] as $i => $attendee) { + foreach ((array) ($task['attendees'] ?? []) as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { return $i; } @@ -1732,7 +1751,7 @@ class tasklist extends rcube_plugin $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 = ['id' => $task, 'list' => $list, 'rev' => $rev]; $attachment = $this->driver->get_attachment($id, $task); // show part page @@ -1741,7 +1760,9 @@ class tasklist extends rcube_plugin } // deliver attachment content else if ($attachment) { - $attachment['body'] = $this->driver->get_attachment_body($id, $task); + if (empty($attachment['body'])) { + $attachment['body'] = $this->driver->get_attachment_body($id, $task); + } $handler->attachment_get($attachment); } diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index 249cfe7c..32a9caa6 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -21,13 +21,13 @@ * along with this program. If not, see . */ - +#[AllowDynamicProperties] class tasklist_ui { private $rc; private $plugin; private $ready = false; - private $gui_objects = array(); + private $gui_objects = []; function __construct($plugin) { @@ -74,7 +74,7 @@ class tasklist_ui */ function load_settings() { - $settings = array(); + $settings = []; $settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0); $settings['itip_notify'] = (int)$this->rc->config->get('calendar_itip_send_option', 3); @@ -120,7 +120,7 @@ class tasklist_ui /** * Render a HTML select box for user identity selection */ - function identity_select($attrib = array()) + function identity_select($attrib = []) { $attrib['name'] = 'identity'; $select = new html_select($attrib); @@ -165,10 +165,10 @@ class tasklist_ui /** * */ - public function tasklists($attrib = array()) + public function tasklists($attrib = []) { - $tree = true; - $jsenv = array(); + $tree = true; + $jsenv = []; $lists = $this->plugin->driver->get_lists(0, $tree); if (empty($attrib['id'])) { @@ -181,18 +181,18 @@ class tasklist_ui } else { // fall-back to flat folder listing - $attrib['class'] .= ' flat'; - + $attrib['class'] = ($attrib['class'] ?? '') . ' flat'; $html = ''; - foreach ((array)$lists as $id => $prop) { + + foreach ((array) $lists as $id => $prop) { if (!empty($attrib['activeonly']) && empty($prop['active'])) { continue; } - $html .= html::tag('li', array( + $html .= html::tag('li', [ 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), - 'class' => isset($prop['group']) ? $prop['group'] : null, - ), + 'class' => $prop['group'] ?? null, + ], $this->tasklist_list_item($id, $prop, $jsenv, !empty($attrib['activeonly'])) ); } @@ -288,15 +288,19 @@ class tasklist_ui 'aria-labelledby' => $label_id )); + $actions = ''; + if (!empty($prop['removable'])) { + $actions .= html::a(['href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')], ' '); + } + $actions .= html::a(['href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'], ' '); + if (isset($prop['subscribed'])) { + $action .= html::a(['href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'], ' '); + } + return html::div(join(' ', $classes), - html::a(array('class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id), - !empty($prop['listname']) ? $prop['listname'] : $prop['name']) . - (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', - (!empty($prop['removable']) ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '') - . html::a(array('href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'), ' ') - . (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '') - ) - ) + html::a(['class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id], + !empty($prop['listname']) ? $prop['listname'] : $prop['name']) + . (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', $actions)) ); }