diff --git a/plugins/libkolab/lib/kolab_format_task.php b/plugins/libkolab/lib/kolab_format_task.php index 465ba901..b3c6a42e 100644 --- a/plugins/libkolab/lib/kolab_format_task.php +++ b/plugins/libkolab/lib/kolab_format_task.php @@ -1,130 +1,130 @@ * * 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 kolab_format_task extends kolab_format_xcal { public $CTYPEv2 = 'application/x-vnd.kolab.task'; protected $objclass = 'Todo'; protected $read_func = 'readTodo'; protected $write_func = 'writeTodo'; /** * Set properties to the kolabformat object * * @param array Object data as hash array */ public function set(&$object) { // set common xcal properties parent::set($object); $this->obj->setPercentComplete(intval($object['complete'])); $status = kolabformat::StatusUndefined; - if ($object['complete'] == 100) + if ($object['complete'] == 100 && !array_key_exists('status', $object)) $status = kolabformat::StatusCompleted; else if ($object['status'] && array_key_exists($object['status'], $this->status_map)) $status = $this->status_map[$object['status']]; $this->obj->setStatus($status); $this->obj->setStart(self::get_datetime($object['start'], null, $object['start']->_dateonly)); $this->obj->setDue(self::get_datetime($object['due'], null, $object['due']->_dateonly)); $related = new vectors; if (!empty($object['parent_id'])) $related->push($object['parent_id']); $this->obj->setRelatedTo($related); // cache this data $this->data = $object; unset($this->data['_formatobj']); } /** * */ public function is_valid() { return !$this->formaterror && ($this->data || (is_object($this->obj) && $this->obj->isValid())); } /** * Convert the Configuration object into a hash array data structure * * @param array Additional data for merge * * @return array Config object data as hash array */ public function to_array($data = array()) { // return cached result if (!empty($this->data)) return $this->data; // read common xcal props $object = parent::to_array($data); $object['complete'] = intval($this->obj->percentComplete()); // if due date is set if ($due = $this->obj->due()) $object['due'] = self::php_datetime($due); // related-to points to parent task; we only support one relation $related = self::vector2array($this->obj->relatedTo()); if (count($related)) $object['parent_id'] = $related[0]; // TODO: map more properties $this->data = $object; return $this->data; } /** * Callback for kolab_storage_cache to get object specific tags to cache * * @return array List of tags to save in cache */ public function get_tags() { $tags = array(); - if ($this->data['status'] == 'COMPLETED' || $this->data['complete'] == 100) + if ($this->data['status'] == 'COMPLETED' || ($this->data['complete'] == 100 && empty($this->data['status']))) $tags[] = 'x-complete'; if ($this->data['priority'] == 1) $tags[] = 'x-flagged'; if (!empty($this->data['valarms'])) $tags[] = 'x-has-alarms'; if ($this->data['parent_id']) $tags[] = 'x-parent:' . $this->data['parent_id']; return $tags; } } diff --git a/plugins/tasklist/drivers/database/SQL/mysql.initial.sql b/plugins/tasklist/drivers/database/SQL/mysql.initial.sql index 2746ca4c..aae636da 100644 --- a/plugins/tasklist/drivers/database/SQL/mysql.initial.sql +++ b/plugins/tasklist/drivers/database/SQL/mysql.initial.sql @@ -1,51 +1,52 @@ /** * Roundcube Tasklist plugin database * * @version @package_version@ * @author Thomas Bruederli * @licence GNU AGPL * @copyright (C) 2012, Kolab Systems AG */ CREATE TABLE IF NOT EXISTS `tasklists` ( `tasklist_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(10) unsigned NOT NULL, `name` varchar(255) NOT NULL, `color` varchar(8) NOT NULL, `showalarms` tinyint(2) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`tasklist_id`), KEY `user_id` (`user_id`), CONSTRAINT `fk_tasklist_user_id` FOREIGN KEY (`user_id`) REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE ) /*!40000 ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `tasks` ( `task_id` int(10) unsigned NOT NULL AUTO_INCREMENT, `tasklist_id` int(10) unsigned NOT NULL, `parent_id` int(10) unsigned DEFAULT NULL, `uid` varchar(255) NOT NULL, `created` datetime NOT NULL, `changed` datetime NOT NULL, `del` tinyint(1) unsigned NOT NULL DEFAULT '0', `title` varchar(255) NOT NULL, `description` text, `tags` text, `date` varchar(10) DEFAULT NULL, `time` varchar(5) DEFAULT NULL, `startdate` varchar(10) DEFAULT NULL, `starttime` varchar(5) DEFAULT NULL, `flagged` tinyint(4) NOT NULL DEFAULT '0', `complete` float NOT NULL DEFAULT '0', + `status` enum('','NEEDS-ACTION','IN-PROCESS','COMPLETED','CANCELLED') NOT NULL DEFAULT '', `alarms` varchar(255) DEFAULT NULL, `recurrence` varchar(255) DEFAULT NULL, `organizer` varchar(255) DEFAULT NULL, `attendees` text, `notify` datetime DEFAULT NULL, PRIMARY KEY (`task_id`), KEY `tasklisting` (`tasklist_id`,`del`,`date`), KEY `uid` (`uid`), CONSTRAINT `fk_tasks_tasklist_id` FOREIGN KEY (`tasklist_id`) REFERENCES `tasklists`(`tasklist_id`) ON DELETE CASCADE ON UPDATE CASCADE ) /*!40000 ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_general_ci */; -REPLACE INTO `system` (`name`, `value`) VALUES ('tasklist-database-version', '2013011000'); +REPLACE INTO `system` (`name`, `value`) VALUES ('tasklist-database-version', '2014051900'); diff --git a/plugins/tasklist/drivers/database/SQL/mysql/2014051900.sql b/plugins/tasklist/drivers/database/SQL/mysql/2014051900.sql new file mode 100644 index 00000000..e5f9a620 --- /dev/null +++ b/plugins/tasklist/drivers/database/SQL/mysql/2014051900.sql @@ -0,0 +1,3 @@ +ALTER TABLE `tasks` ADD `status` ENUM('','NEEDS-ACTION','IN-PROCESS','COMPLETED','CANCELLED') NOT NULL DEFAULT '' AFTER `complete`; + +UPDATE `tasks` SET status='COMPLETED' WHERE complete=1.0 AND status=''; diff --git a/plugins/tasklist/drivers/database/SQL/postgres.initial.sql b/plugins/tasklist/drivers/database/SQL/postgres.initial.sql index 4a445128..e5665c2d 100644 --- a/plugins/tasklist/drivers/database/SQL/postgres.initial.sql +++ b/plugins/tasklist/drivers/database/SQL/postgres.initial.sql @@ -1,63 +1,64 @@ /** * Roundcube Tasklist plugin database * * @version @package_version@ * @author Thomas Bruederli * @licence GNU AGPL * @copyright (C) 2014, Kolab Systems AG */ CREATE SEQUENCE tasklists_seq INCREMENT BY 1 NO MAXVALUE NO MINVALUE CACHE 1; CREATE TABLE tasklists ( tasklist_id integer DEFAULT nextval('tasklists_seq'::regclass) NOT NULL, user_id integer NOT NULL REFERENCES users (user_id) ON UPDATE CASCADE ON DELETE CASCADE, name varchar(255) NOT NULL, color varchar(8) NOT NULL, showalarms smallint NOT NULL DEFAULT 0, PRIMARY KEY (tasklist_id) ); CREATE INDEX tasklists_user_id_idx ON tasklists (user_id, name); CREATE SEQUENCE tasks_seq INCREMENT BY 1 NO MAXVALUE NO MINVALUE CACHE 1; CREATE TABLE tasks ( task_id integer DEFAULT nextval('tasks_seq'::regclass) NOT NULL, tasklist_id integer NOT NULL REFERENCES tasklists (tasklist_id) ON UPDATE CASCADE ON DELETE CASCADE, parent_id integer DEFAULT NULL, uid varchar(255) NOT NULL, created timestamp without time zone DEFAULT now() NOT NULL, changed timestamp without time zone DEFAULT now() NOT NULL, del smallint NOT NULL DEFAULT '0', title varchar(255) NOT NULL, description text, tags text, date varchar(10) DEFAULT NULL, time varchar(5) DEFAULT NULL, startdate varchar(10) DEFAULT NULL, starttime varchar(5) DEFAULT NULL, flagged smallint NOT NULL DEFAULT 0, complete float NOT NULL DEFAULT 0, + status varchar(16) NOT NULL DEFAULT '', alarms varchar(255) DEFAULT NULL, recurrence varchar(255) DEFAULT NULL, organizer varchar(255) DEFAULT NULL, attendees text, notify timestamp without time zone DEFAULT NULL, PRIMARY KEY (task_id) ); CREATE INDEX tasks_tasklisting_idx ON tasks (tasklist_id, del, date); CREATE INDEX tasks_uid_idx ON tasks (uid); -INSERT INTO system (name, value) VALUES ('tasklist-database-version', '2013011000'); +INSERT INTO system (name, value) VALUES ('tasklist-database-version', '2014051900'); diff --git a/plugins/tasklist/drivers/database/tasklist_database_driver.php b/plugins/tasklist/drivers/database/tasklist_database_driver.php index d9bf4145..6c1e0ea4 100644 --- a/plugins/tasklist/drivers/database/tasklist_database_driver.php +++ b/plugins/tasklist/drivers/database/tasklist_database_driver.php @@ -1,808 +1,811 @@ * * 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_database_driver extends tasklist_driver { + const IS_COMPLETE_SQL = "(status='COMPLETED' OR (complete=1 AND status=''))"; + public $undelete = true; // yes, we can public $sortable = false; public $alarm_types = array('DISPLAY'); private $rc; private $plugin; private $lists = array(); private $list_ids = ''; private $db_tasks = 'tasks'; private $db_lists = 'tasklists'; /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; // read database config $db = $this->rc->get_dbh(); $this->db_lists = $this->rc->config->get('db_table_lists', $db->table_name($this->db_lists)); $this->db_tasks = $this->rc->config->get('db_table_tasks', $db->table_name($this->db_tasks)); $this->_read_lists(); } /** * Read available calendars for the current user and store them internally */ private function _read_lists() { $hidden = array_filter(explode(',', $this->rc->config->get('hidden_tasklists', ''))); if (!empty($this->rc->user->ID)) { $list_ids = array(); $result = $this->rc->db->query( "SELECT *, tasklist_id AS id FROM " . $this->db_lists . " WHERE user_id=? ORDER BY CASE WHEN name='INBOX' THEN 0 ELSE 1 END, name", $this->rc->user->ID ); while ($result && ($arr = $this->rc->db->fetch_assoc($result))) { $arr['showalarms'] = intval($arr['showalarms']); $arr['active'] = !in_array($arr['id'], $hidden); $arr['name'] = html::quote($arr['name']); $arr['listname'] = html::quote($arr['name']); $arr['editable'] = true; $this->lists[$arr['id']] = $arr; $list_ids[] = $this->rc->db->quote($arr['id']); } $this->list_ids = join(',', $list_ids); } } /** * Get a list of available tasks lists from this source */ public function get_lists() { // attempt to create a default list for this user if (empty($this->lists)) { $prop = array('name' => 'Default', 'color' => '000000'); if ($this->create_list($prop)) $this->_read_lists(); } return $this->lists; } /** * Create a new list assigned to the current user * * @param array Hash array with list properties * @return mixed ID of the new list on success, False on error * @see tasklist_driver::create_list() */ public function create_list(&$prop) { $result = $this->rc->db->query( "INSERT INTO " . $this->db_lists . " (user_id, name, color, showalarms) VALUES (?, ?, ?, ?)", $this->rc->user->ID, strval($prop['name']), strval($prop['color']), $prop['showalarms']?1:0 ); if ($result) return $this->rc->db->insert_id($this->db_lists); return false; } /** * Update properties of an existing tasklist * * @param array Hash array with list properties * @return boolean True on success, Fales on failure * @see tasklist_driver::edit_list() */ public function edit_list(&$prop) { $query = $this->rc->db->query( "UPDATE " . $this->db_lists . " SET name=?, color=?, showalarms=? WHERE tasklist_id=? AND user_id=?", $prop['name'], $prop['color'], $prop['showalarms']?1:0, $prop['id'], $this->rc->user->ID ); return $this->rc->db->affected_rows($query); } /** * Set active/subscribed state of a list * * @param array Hash array with list properties * @return boolean True on success, Fales on failure * @see tasklist_driver::subscribe_list() */ public function subscribe_list($prop) { $hidden = array_flip(explode(',', $this->rc->config->get('hidden_tasklists', ''))); if ($prop['active']) unset($hidden[$prop['id']]); else $hidden[$prop['id']] = 1; return $this->rc->user->save_prefs(array('hidden_tasklists' => join(',', array_keys($hidden)))); } /** * Delete the given list with all its contents * * @param array Hash array with list properties * @return boolean True on success, Fales on failure * @see tasklist_driver::remove_list() */ public function remove_list($prop) { $list_id = $prop['id']; if ($this->lists[$list_id]) { // delete all tasks linked with this list $this->rc->db->query( "DELETE FROM " . $this->db_tasks . " WHERE tasklist_id=?", $list_id ); // delete list record $query = $this->rc->db->query( "DELETE FROM " . $this->db_lists . " WHERE tasklist_id=? AND user_id=?", $list_id, $this->rc->user->ID ); return $this->rc->db->affected_rows($query); } return false; } /** * 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|today|tomorrow|overdue|nodate) * @see tasklist_driver::count_tasks() */ function count_tasks($lists = null) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); // only allow to select from lists of this user $list_ids = array_map(array($this->rc->db, 'quote'), array_intersect($lists, array_keys($this->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'); $result = $this->rc->db->query(sprintf( "SELECT task_id, flagged, date FROM " . $this->db_tasks . " WHERE tasklist_id IN (%s) - AND del=0 AND complete<1", + AND del=0 AND NOT " . self::IS_COMPLETE_SQL, join(',', $list_ids) )); $counts = array('all' => 0, 'flagged' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'nodate' => 0); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $counts['all']++; if ($rec['flagged']) $counts['flagged']++; if (empty($rec['date'])) $counts['nodate']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; } return $counts; } /** * Get all taks records matching the given filter * * @param array Hash array wiht filter criterias * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria * @see tasklist_driver::list_tasks() */ function list_tasks($filter, $lists = null) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); // only allow to select from lists of this user $list_ids = array_map(array($this->rc->db, 'quote'), array_intersect($lists, array_keys($this->lists))); $sql_add = ''; // add filter criteria if ($filter['from'] || ($filter['mask'] & tasklist::FILTER_MASK_TODAY)) { $sql_add .= ' AND (date IS NULL OR date >= ?)'; $datefrom = $filter['from']; } if ($filter['to']) { if ($filter['mask'] & tasklist::FILTER_MASK_OVERDUE) $sql_add .= ' AND (date IS NOT NULL AND date <= ' . $this->rc->db->quote($filter['to']) . ')'; else $sql_add .= ' AND (date IS NULL OR date <= ' . $this->rc->db->quote($filter['to']) . ')'; } // special case 'today': also show all events with date before today if ($filter['mask'] & tasklist::FILTER_MASK_TODAY) { $datefrom = date('Y-m-d', 0); } if ($filter['mask'] & tasklist::FILTER_MASK_NODATE) $sql_add = ' AND date IS NULL'; if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) - $sql_add .= ' AND complete=1'; + $sql_add .= ' AND ' . self::IS_COMPLETE_SQL; else if (empty($filter['since'])) // don't show complete tasks by default - $sql_add .= ' AND complete<1'; + $sql_add .= ' AND NOT ' . self::IS_COMPLETE_SQL; if ($filter['mask'] & tasklist::FILTER_MASK_FLAGGED) $sql_add .= ' AND flagged=1'; // compose (slow) SQL query for searching // FIXME: improve searching using a dedicated col and normalized values if ($filter['search']) { $sql_query = array(); foreach (array('title','description','organizer','attendees') as $col) $sql_query[] = $this->rc->db->ilike($col, '%'.$filter['search'].'%'); $sql_add = 'AND (' . join(' OR ', $sql_query) . ')'; } if ($filter['since'] && is_numeric($filter['since'])) { $sql_add .= ' AND changed >= ' . $this->rc->db->quote(date('Y-m-d H:i:s', $filter['since'])); } $tasks = array(); if (!empty($list_ids)) { $result = $this->rc->db->query(sprintf( "SELECT * FROM " . $this->db_tasks . " WHERE tasklist_id IN (%s) AND del=0 %s ORDER BY parent_id, task_id ASC", join(',', $list_ids), $sql_add ), $datefrom ); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $tasks[] = $this->_read_postprocess($rec); } } return $tasks; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @return array Hash array with task properties or false if not found */ public function get_task($prop) { if (is_string($prop)) $prop['uid'] = $prop; $query_col = $prop['id'] ? 'task_id' : 'uid'; $result = $this->rc->db->query(sprintf( "SELECT * FROM " . $this->db_tasks . " WHERE tasklist_id IN (%s) AND %s=? AND del=0", $this->list_ids, $query_col ), $prop['id'] ? $prop['id'] : $prop['uid'] ); if ($result && ($rec = $this->rc->db->fetch_assoc($result))) { return $this->_read_postprocess($rec); } return false; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * @return array List of all child task IDs */ public function get_childs($prop, $recursive = false) { // resolve UID first if (is_string($prop)) { $result = $this->rc->db->query(sprintf( "SELECT task_id AS id, tasklist_id AS list FROM " . $this->db_tasks . " WHERE tasklist_id IN (%s) AND uid=?", $this->list_ids ), $prop); $prop = $this->rc->db->fetch_assoc($result); } $childs = array(); $task_ids = array($prop['id']); // query for childs (recursively) while (!empty($task_ids)) { $result = $this->rc->db->query(sprintf( "SELECT task_id AS id FROM " . $this->db_tasks . " WHERE tasklist_id IN (%s) AND parent_id IN (%s) AND del=0", $this->list_ids, join(',', array_map(array($this->rc->db, 'quote'), $task_ids)) )); $task_ids = array(); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $childs[] = $rec['id']; $task_ids[] = $rec['id']; } if (!$recursive) break; } return $childs; } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed 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) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); // only allow to select from calendars with activated alarms $list_ids = array(); foreach ($lists as $lid) { if ($this->lists[$lid] && $this->lists[$lid]['showalarms']) $list_ids[] = $lid; } $list_ids = array_map(array($this->rc->db, 'quote'), $list_ids); $alarms = array(); if (!empty($list_ids)) { $result = $this->rc->db->query(sprintf( "SELECT * FROM " . $this->db_tasks . " WHERE tasklist_id IN (%s) - AND notify <= %s AND complete < 1", + AND notify <= %s AND NOT " . self::IS_COMPLETE_SQL, join(',', $list_ids), $this->rc->db->fromunixtime($time) )); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) $alarms[] = $this->_read_postprocess($rec); } return $alarms; } /** * Feedback after showing/sending an alarm notification * * @see tasklist_driver::dismiss_alarm() */ public function dismiss_alarm($task_id, $snooze = 0) { // set new notifyat time or unset if not snoozed $notify_at = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query(sprintf( "UPDATE " . $this->db_tasks . " SET changed=%s, notify=? WHERE task_id=? AND tasklist_id IN (" . $this->list_ids . ")", $this->rc->db->now()), $notify_at, $task_id ); return $this->rc->db->affected_rows($query); } /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ public function clear_alarms($id) { // Nothing to do here. Alarms are reset in edit_task() } /** * Map some internal database values to match the generic "API" */ private function _read_postprocess($rec) { $rec['id'] = $rec['task_id']; $rec['list'] = $rec['tasklist_id']; $rec['changed'] = new DateTime($rec['changed']); $rec['tags'] = array_filter(explode(',', $rec['tags'])); if (!$rec['parent_id']) unset($rec['parent_id']); // decode serialized alarms if ($rec['alarms']) { $rec['valarms'] = $this->unserialize_alarms($rec['alarms']); unset($rec['alarms']); } // decode serialze recurrence rules if ($rec['recurrence']) { $rec['recurrence'] = $this->unserialize_recurrence($rec['recurrence']); } unset($rec['task_id'], $rec['tasklist_id'], $rec['created']); return $rec; } /** * Add a single task to the database * * @param array Hash array with task properties (see header of this file) * @return mixed New event ID on success, False on error * @see tasklist_driver::create_task() */ public function create_task($prop) { // check list permissions $list_id = $prop['list'] ? $prop['list'] : reset(array_keys($this->lists)); if (!$this->lists[$list_id] || $this->lists[$list_id]['readonly']) return false; if (is_array($prop['valarms'])) { $prop['alarms'] = $this->serialize_alarms($prop['valarms']); } if (is_array($prop['recurrence'])) { $prop['recurrence'] = $this->serialize_recurrence($prop['recurrence']); } - foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms', 'recurrence') as $col) { + foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms', 'recurrence', 'status') as $col) { if (empty($prop[$col])) $prop[$col] = null; } $notify_at = $this->_get_notification($prop); $result = $this->rc->db->query(sprintf( "INSERT INTO " . $this->db_tasks . " - (tasklist_id, uid, parent_id, created, changed, title, date, time, startdate, starttime, description, tags, flagged, complete, alarms, recurrence, notify) - VALUES (?, ?, ?, %s, %s, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (tasklist_id, uid, parent_id, created, changed, title, date, time, startdate, starttime, description, tags, flagged, complete, status, alarms, recurrence, notify) + VALUES (?, ?, ?, %s, %s, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", $this->rc->db->now(), $this->rc->db->now() ), $list_id, $prop['uid'], $prop['parent_id'], $prop['title'], $prop['date'], $prop['time'], $prop['startdate'], $prop['starttime'], strval($prop['description']), join(',', (array)$prop['tags']), $prop['flagged'] ? 1 : 0, intval($prop['complete']), + $prop['status'], $prop['alarms'], $prop['recurrence'], $notify_at ); if ($result) return $this->rc->db->insert_id($this->db_tasks); return false; } /** * Update an task entry with the given data * * @param array Hash array with task properties * @return boolean True on success, False on error * @see tasklist_driver::edit_task() */ public function edit_task($prop) { if (is_array($prop['valarms'])) { $prop['alarms'] = $this->serialize_alarms($prop['valarms']); } if (is_array($prop['recurrence'])) { $prop['recurrence'] = $this->serialize_recurrence($prop['recurrence']); } $sql_set = array(); foreach (array('title', 'description', 'flagged', 'complete') as $col) { if (isset($prop[$col])) $sql_set[] = $this->rc->db->quote_identifier($col) . '=' . $this->rc->db->quote($prop[$col]); } - foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms', 'recurrence') as $col) { + foreach (array('parent_id', 'date', 'time', 'startdate', 'starttime', 'alarms', 'recurrence', 'status') as $col) { if (isset($prop[$col])) $sql_set[] = $this->rc->db->quote_identifier($col) . '=' . (empty($prop[$col]) ? 'NULL' : $this->rc->db->quote($prop[$col])); } if (isset($prop['tags'])) $sql_set[] = $this->rc->db->quote_identifier('tags') . '=' . $this->rc->db->quote(join(',', (array)$prop['tags'])); if (isset($prop['date']) || isset($prop['time']) || isset($prop['alarms'])) { $notify_at = $this->_get_notification($prop); $sql_set[] = $this->rc->db->quote_identifier('notify') . '=' . (empty($notify_at) ? 'NULL' : $this->rc->db->quote($notify_at)); } // moved from another list if ($prop['_fromlist'] && ($newlist = $prop['list'])) { $sql_set[] = 'tasklist_id=' . $this->rc->db->quote($newlist); } $query = $this->rc->db->query(sprintf( "UPDATE " . $this->db_tasks . " SET changed=%s %s WHERE task_id=? AND tasklist_id IN (%s)", $this->rc->db->now(), ($sql_set ? ', ' . join(', ', $sql_set) : ''), $this->list_ids ), $prop['id'] ); return $this->rc->db->affected_rows($query); } /** * Move a single task to another list * * @param array Hash array with task properties: * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($prop) { return $this->edit_task($prop); } /** * Remove a single task from the database * * @param array Hash array with task properties * @param boolean Remove record irreversible * @return boolean True on success, False on error * @see tasklist_driver::delete_task() */ public function delete_task($prop, $force = true) { $task_id = $prop['id']; if ($task_id && $force) { $query = $this->rc->db->query( "DELETE FROM " . $this->db_tasks . " WHERE task_id=? AND tasklist_id IN (" . $this->list_ids . ")", $task_id ); } else if ($task_id) { $query = $this->rc->db->query(sprintf( "UPDATE " . $this->db_tasks . " SET changed=%s, del=1 WHERE task_id=? AND tasklist_id IN (%s)", $this->rc->db->now(), $this->list_ids ), $task_id ); } return $this->rc->db->affected_rows($query); } /** * Restores a single deleted task (if supported) * * @param array Hash array with task properties * @return boolean True on success, False on error * @see tasklist_driver::undelete_task() */ public function undelete_task($prop) { $query = $this->rc->db->query(sprintf( "UPDATE " . $this->db_tasks . " SET changed=%s, del=0 WHERE task_id=? AND tasklist_id IN (%s)", $this->rc->db->now(), $this->list_ids ), $prop['id'] ); return $this->rc->db->affected_rows($query); } /** * Compute absolute time to notify the user */ private function _get_notification($task) { - if ($task['valarms'] && $task['complete'] < 1) { + if ($task['valarms'] && !$this->is_complete($task)) { $alarm = libcalendaring::get_next_alarm($task, 'task'); if ($alarm['time'] && in_array($alarm['action'], $this->alarm_types)) return date('Y-m-d H:i:s', $alarm['time']); } return null; } /** * Helper method to serialize the list of alarms into a string */ private function serialize_alarms($valarms) { foreach ((array)$valarms as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $valarms[$i]['trigger'] = '@' . $alarm['trigger']->format('c'); } } return $valarms ? json_encode($valarms) : null; } /** * Helper method to decode a serialized list of alarms */ private function unserialize_alarms($alarms) { // decode json serialized alarms if ($alarms && $alarms[0] == '[') { $valarms = json_decode($alarms, true); foreach ($valarms as $i => $alarm) { if ($alarm['trigger'][0] == '@') { try { $valarms[$i]['trigger'] = new DateTime(substr($alarm['trigger'], 1)); } catch (Exception $e) { unset($valarms[$i]); } } } } // convert legacy alarms data else if (strlen($alarms)) { list($trigger, $action) = explode(':', $alarms, 2); if ($trigger = libcalendaring::parse_alaram_value($trigger)) { $valarms = array(array('action' => $action, 'trigger' => $trigger[3] ?: $trigger[0])); } } return $valarms; } /** * Helper method to serialize task recurrence properties */ private function serialize_recurrence($recurrence) { foreach ((array)$recurrence as $k => $val) { if ($val instanceof DateTime) { $recurrence[$k] = '@' . $val->format('c'); } } return $recurrence ? json_encode($recurrence) : null; } /** * Helper method to decode a serialized task recurrence struct */ private function unserialize_recurrence($ser) { if (strlen($ser)) { $recurrence = json_decode($ser, true); foreach ((array)$recurrence as $k => $val) { if ($val[0] == '@') { try { $recurrence[$k] = new DateTime(substr($val, 1)); } catch (Exception $e) { unset($recurrence[$k]); } } } } else { $recurrence = ''; } return $recurrence; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $db = $this->rc->db; $list_ids = array(); $lists = $db->query("SELECT tasklist_id FROM " . $this->db_lists . " WHERE user_id=?", $args['user']->ID); while ($row = $db->fetch_assoc($lists)) { $list_ids[] = $row['tasklist_id']; } if (!empty($list_ids)) { foreach (array($this->db_tasks, $this->db_lists) as $table) { $db->query(sprintf("DELETE FROM $table WHERE tasklist_id IN (%s)", join(',', $list_ids))); } } } } diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php index ad36777b..31185231 100644 --- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -1,1033 +1,1034 @@ * * 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_kolab_driver extends tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = true; public $undelete = false; // task undelete action public $alarm_types = array('DISPLAY','AUDIO'); private $rc; private $plugin; private $lists; private $folders = array(); private $tasks = array(); /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; $this->_read_lists(); if (kolab_storage::$version == '2.0') { $this->alarm_absolute = false; } $this->plugin->register_action('folder-acl', array($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 = kolab_storage::sort_folders(kolab_storage::get_folders('task')); $this->lists = $this->folders = array(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default) $default_index = $i; } // put default folder (aka INBOX) on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $prefs = $this->rc->config->get('kolab_tasklists', array()); $listnames = array(); // include virtual folders for a full folder tree if (!$this->rc->output->ajax_call && in_array($this->rc->action, array('index',''))) $folders = kolab_storage::folder_hierarchy($folders); foreach ($folders as $folder) { $utf7name = $folder->name; $path_imap = explode($delim, $utf7name); $editname = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP'); // pop off raw name part $path_imap = join($delim, $path_imap); $fullname = $folder->get_name(); $listname = kolab_storage::folder_displayname($fullname, $listnames); // special handling for virtual folders if ($folder->virtual) { $list_id = kolab_storage::folder_id($utf7name); $this->lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'virtual' => true, 'editable' => false, ); continue; } if ($folder->get_namespace() == 'personal') { $norename = false; $readonly = false; $alarms = true; } else { $alarms = false; $readonly = true; if (($rights = $folder->get_myrights()) && !PEAR::isError($rights)) { if (strpos($rights, 'i') !== false) $readonly = false; } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = kolab_storage::folder_id($utf7name); $tasklist = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'editname' => $editname, 'color' => $folder->get_color('0000CC'), 'showalarms' => isset($prefs[$list_id]['showalarms']) ? $prefs[$list_id]['showalarms'] : $alarms, 'editable' => !$readionly, 'norename' => $norename, 'active' => $folder->is_active(), 'parentfolder' => $path_imap, 'default' => $folder->default, 'children' => true, // TODO: determine if that folder indeed has child folders 'class_name' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), ); $this->lists[$tasklist['id']] = $tasklist; $this->folders[$tasklist['id']] = $folder; $this->folders[$folder->name] = $folder; } } /** * Get a list of available task lists from this source */ public function get_lists() { // attempt to create a default list for this user if (empty($this->lists)) { if ($this->create_list(array('name' => 'Tasks', 'color' => '0000CC', 'default' => true))) $this->_read_lists(true); } return $this->lists; } /** * 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' . ($prop['default'] ? '.default' : ''); $prop['active'] = true; // activate folder by default $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($folder); $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($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; 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 boolean True on success, Fales on failure */ public function edit_list(&$prop) { if ($prop['id'] && ($folder = $this->folders[$prop['id']])) { $prop['oldname'] = $folder->name; $prop['type'] = 'task'; $newfolder = kolab_storage::folder_update($prop); if ($newfolder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($newfolder); // fallback to local prefs $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); unset($prefs['kolab_tasklists'][$prop['id']]); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($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 $id; } 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 * @return boolean True on success, Fales on failure */ public function subscribe_list($prop) { if ($prop['id'] && ($folder = $this->folders[$prop['id']])) { return $folder->activate($prop['active']); } return false; } /** * Delete the given list with all its contents * * @param array Hash array with list properties * id: list Identifier * @return boolean True on success, Fales on failure */ public function remove_list($prop) { if ($prop['id'] && ($folder = $this->folders[$prop['id']])) { if (kolab_storage::folder_delete($folder->name)) return true; else $this->last_error = kolab_storage::$last_error; } return false; } /** * 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 = array_keys($this->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 = array('all' => 0, 'flagged' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'nodate' => 0); foreach ($lists as $list_id) { $folder = $this->folders[$list_id]; foreach ($folder->select(array(array('tags','!~','x-complete'))) as $record) { $rec = $this->_to_rcube_task($record); - if ($rec['complete'] >= 1.0) // don't count complete tasks + if ($this->is_complete($rec)) // don't count complete tasks continue; $counts['all']++; if ($rec['flagged']) $counts['flagged']++; if (empty($rec['date'])) $counts['nodate']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; } } return $counts; } /** * Get all taks 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 * @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 = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); $results = array(); // query Kolab storage $query = array(); if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $query[] = array('tags','~','x-complete'); else if (empty($filter['since'])) $query[] = array('tags','!~','x-complete'); // full text search (only works with cache enabled) if ($filter['search']) { $search = mb_strtolower($filter['search']); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', '~', $word); } } if ($filter['since']) { $query[] = array('changed', '>=', $filter['since']); } foreach ($lists as $list_id) { $folder = $this->folders[$list_id]; foreach ($folder->select($query) as $record) { $task = $this->_to_rcube_task($record); $task['list'] = $list_id; // TODO: post-filter tasks returned from storage $results[] = $task; } } return $results; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @return array Hash array with task properties or false if not found */ public function get_task($prop) { $id = is_array($prop) ? ($prop['uid'] ?: $prop['id']) : $prop; $list_id = is_array($prop) ? $prop['list'] : null; $folders = $list_id ? array($list_id => $this->folders[$list_id]) : $this->folders; // find task in the available folders foreach ($folders as $list_id => $folder) { if (is_numeric($list_id)) continue; if (!$this->tasks[$id] && ($object = $folder->get_object($id))) { $this->tasks[$id] = $this->_to_rcube_task($object); $this->tasks[$id]['list'] = $list_id; break; } } return $this->tasks[$id]; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean 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 = array('id' => $task['id'], 'list' => $task['list']); } $childs = array(); $list_id = $prop['list']; $task_ids = array($prop['id']); $folder = $this->folders[$list_id]; // query for childs (recursively) while ($folder && !empty($task_ids)) { $query_ids = array(); foreach ($task_ids as $task_id) { $query = array(array('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[] = $record['uid']; $query_ids[] = $record['uid']; } } } if (!$recursive) break; $task_ids = $query_ids; } return $childs; } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed 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 array(); if ($lists && is_string($lists)) $lists = explode(',', $lists); $time = $slot + $interval; $candidates = array(); $query = array(array('tags', '=', 'x-has-alarms'), array('tags', '!=', 'x-complete')); foreach ($this->lists as $lid => $list) { // skip lists with alarms disabled if (!$list['showalarms'] || ($lists && !in_array($lid, $lists))) continue; $folder = $this->folders[$lid]; foreach ($folder->select($query) as $record) { if (!($record['valarms'] || $record['alarms']) || $record['status'] == 'COMPLETED' || $record['complete'] == 100) // don't trust query :-) continue; $task = $this->_to_rcube_task($record); // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($task, 'task'); if ($alarm && $alarm['time'] && $alarm['time'] <= $time && in_array($alarm['action'], $this->alarm_types)) { $id = $alarm['id']; // use alarm-id as primary identifier $candidates[$id] = array( 'id' => $id, 'title' => $task['title'], 'date' => $task['date'], 'time' => $task['time'], 'notifyat' => $alarm['time'], 'action' => $alarm['action'], ); } } } // get alarm information stored in local database if (!empty($candidates)) { $alarm_ids = array_map(array($this->rc->db, 'quote'), array_keys($candidates)); $result = $this->rc->db->query(sprintf( "SELECT * FROM kolab_alarms WHERE alarm_id IN (%s) AND user_id=?", join(',', $alarm_ids), $this->rc->db->now() ), $this->rc->user->ID ); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $dbdata[$rec['alarm_id']] = $rec; } } $alarms = array(); foreach ($candidates as $id => $task) { // skip dismissed if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $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 Task identifier * @param integer 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 kolab_alarms 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 kolab_alarms (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 Task identifier */ public function clear_alarms($id) { // delete alarm entry $this->rc->db->query( "DELETE FROM kolab_alarms WHERE alarm_id=? AND user_id=?", $id, $this->rc->user->ID ); return true; } /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_task($record) { $task = array( 'id' => $record['uid'], 'uid' => $record['uid'], 'title' => $record['title'], # 'location' => $record['location'], 'description' => $record['description'], 'tags' => array_filter((array)$record['categories']), 'flagged' => $record['priority'] == 1, - 'complete' => $record['status'] == 'COMPLETED' ? 1 : floatval($record['complete'] / 100), + 'complete' => floatval($record['complete'] / 100), + 'status' => $record['status'], 'parent_id' => $record['parent_id'], 'recurrence' => $record['recurrence'], ); // convert from DateTime to internal date format if (is_a($record['due'], 'DateTime')) { $due = $this->plugin->lib->adjust_timezone($record['due']); $task['date'] = $due->format('Y-m-d'); if (!$record['due']->_dateonly) $task['time'] = $due->format('H:i'); } // convert from DateTime to internal date format if (is_a($record['start'], 'DateTime')) { $start = $this->plugin->lib->adjust_timezone($record['start']); $task['startdate'] = $start->format('Y-m-d'); if (!$record['start']->_dateonly) $task['starttime'] = $start->format('H:i'); } if (is_a($record['dtstamp'], 'DateTime')) { $task['changed'] = $record['dtstamp']; } if ($record['valarms']) { $task['valarms'] = $record['valarms']; } else if ($record['alarms']) { $task['alarms'] = $record['alarms']; } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$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 = array()) { $object = $task; $object['categories'] = (array)$task['tags']; if (!empty($task['date'])) { $object['due'] = new DateTime($task['date'].' '.$task['time'], $this->plugin->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = new DateTime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; - if ($task['complete'] == 1.0) + if ($task['complete'] == 1.0 && empty($task['complete'])) $object['status'] = 'COMPLETED'; if ($task['flagged']) $object['priority'] = 1; else $object['priority'] = $old['priority'] > 1 ? $old['priority'] : 0; // 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) && $old['recurrence']) { $object['recurrence'] = $old['recurrence']; } // delete existing attachment(s) if (!empty($task['deleted_attachments'])) { foreach ($task['deleted_attachments'] as $attachment) { if (is_array($object['_attachments'])) { foreach ($object['_attachments'] as $idx => $att) { if ($att['id'] == $attachment) $object['_attachments'][$idx] = false; } } } unset($task['deleted_attachments']); } // in kolab_storage attachments are indexed by content-id if (is_array($task['attachments'])) { foreach ($task['attachments'] as $idx => $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it if ($attachment['content']) { unset($attachment['id']); } else { foreach ((array)$old['_attachments'] as $cid => $oldatt) { if ($oldatt && $attachment['id'] == $oldatt['id']) $key = $cid; } } // replace existing entry if ($key) { $object['_attachments'][$key] = $attachment; } // append as new attachment else { $object['_attachments'][] = $attachment; } } unset($object['attachments']); } unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags']); 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 an task entry with the given data * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return boolean True on success, False on error */ public function edit_task($task) { $list_id = $task['list']; if (!$list_id || !($folder = $this->folders[$list_id])) return false; // moved from another folder if ($task['_fromlist'] && ($fromfolder = $this->folders[$task['_fromlist']])) { if (!$fromfolder->move($task['id'], $folder->name)) return false; unset($task['_fromlist']); } // load previous version of this task to merge if ($task['id']) { $old = $folder->get_object($task['id']); if (!$old || PEAR::isError($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); } // generate new task object from RC input $object = $this->_from_rcube_task($task, $old); $saved = $folder->save($object, 'task', $task['id']); if (!$saved) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving task object to Kolab server"), true, false); $saved = false; } else { $task = $this->_to_rcube_task($object); $task['list'] = $list_id; $this->tasks[$task['id']] = $task; } return $saved; } /** * Move a single task to another list * * @param array Hash array with task properties: * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($task) { $list_id = $task['list']; if (!$list_id || !($folder = $this->folders[$list_id])) return false; // execute move command if ($task['_fromlist'] && ($fromfolder = $this->folders[$task['_fromlist']])) { return $fromfolder->move($task['id'], $folder->name); } return false; } /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ public function delete_task($task, $force = true) { $list_id = $task['list']; if (!$list_id || !($folder = $this->folders[$list_id])) return false; return $folder->delete($task['id']); } /** * 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 * * @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) { $task['uid'] = $task['id']; $task = $this->get_task($task); if ($task && !empty($task['attachments'])) { foreach ($task['attachments'] as $att) { if ($att['id'] == $id) 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 * * @return string Attachment body */ public function get_attachment_body($id, $task) { if ($storage = $this->folders[$task['list']]) { return $storage->get_attachment($task['id'], $id); } return false; } /** * */ public function tasklist_edit_form($action, $list, $fieldprop) { if ($list['id'] && ($list = $this->lists[$list['id']])) { $folder_name = $this->folders[$list['id']]->name; // UTF7 } else { $folder_name = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder_name); } else { $path_imap = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'taskedit-tasklistame', 'size' => 20)); $fieldprop['name']['value'] = $input_name->show($list['editname'], array('disabled' => ($options['norename'] || $options['protected']))); // prevent user from moving folder if (!empty($options) && ($options['norename'] || $options['protected'])) { $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('task', array('name' => 'parent', 'id' => 'taskedit-parentfolder'), $folder_name); $fieldprop['parent'] = array( 'id' => 'taskedit-parentfolder', 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show($path_imap), ); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); foreach (array('name','parent','showalarms') as $f) { $form['properties']['fields'][$f] = $fieldprop[$f]; } // add folder ACL tab if ($action != 'form-new') { $form['sharing'] = array( 'name' => Q($this->plugin->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->rc->url(array('_action' => 'folder-acl', '_folder' => $folder_name, 'framed' => 1)), 'width' => '100%', 'height' => 280, 'border' => 0, 'style' => 'border:0'), '') ); } $form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $form_html .= $hiddenfield->show() . "\n"; } } // create form output foreach ($form as $tab) { if (is_array($tab['fields']) && empty($tab['content'])) { $table = new html_table(array('cols' => 2)); foreach ($tab['fields'] as $col => $colprop) { $colprop['id'] = '_'.$col; $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); $table->add('title', html::label($colprop['id'], Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $tab['content']; } if (!empty($content)) { $form_html .= html::tag('fieldset', null, html::tag('legend', null, Q($tab['name'])) . $content) . "\n"; } } return $form_html; } /** * Handler to render ACL form for a notes folder */ public function folder_acl() { $this->plugin->require_plugin('acl'); $this->rc->output->add_handler('folderacl', array($this, 'folder_acl_form')); $this->rc->output->send('tasklist.kolabacl'); } /** * Handler for ACL form template object */ public function folder_acl_form() { $folder = rcube_utils::get_input_value('_folder', RCUBE_INPUT_GPC); if (strlen($folder)) { $storage = $this->rc->get_storage(); $options = $storage->folder_info($folder); // get sharing UI from acl plugin $acl = $this->rc->plugins->exec_hook('folder_form', array('form' => array(), 'options' => $options, 'name' => $folder)); } return $acl['form']['sharing']['content'] ?: html::div('hint', $this->plugin->gettext('aclnorights')); } } diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php index 6c31fa7a..76480f55 100644 --- a/plugins/tasklist/drivers/tasklist_driver.php +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -1,315 +1,326 @@ * * 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 . */ /** * Struct of an internal task object how it is passed from/to the driver classes: * * $task = array( * 'id' => 'Task ID used for editing', // must be unique for the current user * 'parent_id' => 'ID of parent task', // null if top-level task * 'uid' => 'Unique identifier of this task', * 'list' => 'Task list identifier to add the task to or where the task is stored', * 'changed' => , // Last modification date/time of the record * 'title' => 'Event title/summary', * 'description' => 'Event description', * 'tags' => array(), // List of tags for this task * 'date' => 'Due date', // as string of format YYYY-MM-DD or null if no date is set * 'time' => 'Due time', // as string of format hh::ii or null if no due time is set * 'startdate' => 'Start date' // Delay start of the task until that date * 'starttime' => 'Start time' // ...and time * 'categories' => 'Task category', * 'flagged' => 'Boolean value whether this record is flagged', * 'complete' => 'Float value representing the completeness state (range 0..1)', - * 'sensitivity' => 0|1|2, // Event sensitivity (0=public, 1=private, 2=confidential) + * 'status' => 'Task status string according to (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED) RFC 2445', * 'valarms' => array( // List of reminders (new format), each represented as a hash array: * array( * 'trigger' => '-PT90M', // ISO 8601 period string prefixed with '+' or '-', or DateTime object * 'action' => 'DISPLAY|EMAIL|AUDIO', * 'duration' => 'PT15M', // ISO 8601 period string * 'repeat' => 0, // number of repetitions * 'description' => '', // text to display for DISPLAY actions * 'summary' => '', // message text for EMAIL actions * 'attendees' => array(), // list of email addresses to receive alarm messages * ), * ), * 'recurrence' => array( // Recurrence definition according to iCalendar (RFC 2445) specification as list of key-value pairs * 'FREQ' => 'DAILY|WEEKLY|MONTHLY|YEARLY', * 'INTERVAL' => 1...n, * 'UNTIL' => DateTime, * 'COUNT' => 1..n, // number of times * 'RDATE' => array(), // complete list of DateTime objects denoting individual repeat dates * ), * '_fromlist' => 'List identifier where the task was stored before', * ); */ /** * Driver interface for the Tasklist plugin */ abstract class tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = false; public $undelete = false; // task undelete action public $sortable = false; public $alarm_types = array('DISPLAY'); public $alarm_absolute = true; public $last_error; /** * Get a list of available task lists from this source */ abstract function get_lists(); /** * 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 */ abstract function create_list(&$prop); /** * 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 boolean True on success, Fales on failure */ abstract function edit_list(&$prop); /** * 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 * @return boolean True on success, Fales on failure */ abstract function subscribe_list($prop); /** * Delete the given list with all its contents * * @param array Hash array with list properties * id: list Identifier * @return boolean True on success, Fales on failure */ abstract function remove_list($prop); /** * 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) */ abstract function count_tasks($lists = null); /** * Get all taks 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 * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria */ abstract function list_tasks($filter, $lists = null); /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed 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 * id: Task identifier * uid: Unique identifier of this task * date: Task due date * time: Task due time * title: Task title/summary */ abstract function pending_alarms($time, $lists = null); /** * (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 Task identifier * @param integer Suspend the alarm for this number of seconds */ abstract function dismiss_alarm($id, $snooze = 0); /** * Remove alarm dismissal or snooze state * * @param string Task identifier */ abstract public function clear_alarms($id); /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @return array Hash array with task properties or false if not found */ abstract public function get_task($prop); /** * Get decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * @return array List of all child task IDs */ abstract public function get_childs($prop, $recursive = false); /** * Add a single task to the database * * @param array Hash array with task properties (see header of this file) * @return mixed New event ID on success, False on error */ abstract function create_task($prop); /** * Update an task entry with the given data * * @param array Hash array with task properties (see header of this file) * @return boolean True on success, False on error */ abstract function edit_task($prop); /** * Move a single task to another list * * @param array Hash array with task properties: * id: Task identifier * list: New list identifier to move to * _fromlist: Previous list identifier * @return boolean True on success, False on error */ abstract function move_task($prop); /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ abstract function delete_task($prop, $force = true); /** * 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) { return false; } /** * Get attachment properties * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * * @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 attachment body * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * * @return string Attachment body */ public function get_attachment_body($id, $task) { } + /** + * Helper method to determine whether the given task is considered "complete" + * + * @param array $task Hash array with event properties: + * @return boolean True if complete, False otherwiese + */ + public function is_complete($task) + { + return ($task['complete'] >= 1.0 && empty($task['status'])) || $task['status'] === 'COMPLETED'; + } + /** * List availabale categories * The default implementation reads them from config/user prefs */ public function list_categories() { $rcmail = rcube::get_instance(); return $rcmail->config->get('tasklist_categories', array()); } /** * Build the edit/create form for lists. * This gives the drivers the opportunity to add more list properties * * @param string The action called this form * @param array Tasklist properties * @param array List with form fields to be rendered * @return string HTML content of the form */ public function tasklist_edit_form($action, $list, $formfields) { $html = ''; foreach ($formfields as $field) { $html .= html::div('form-section', html::label($field['id'], $field['label']) . $field['value']); } return $html; } /** * Handler for user_delete plugin hook * * @param array Hash array with hook arguments * @return array Return arguments for plugin hooks */ public function user_delete($args) { // TO BE OVERRIDDEN return $args; } } diff --git a/plugins/tasklist/localization/en_US.inc b/plugins/tasklist/localization/en_US.inc index 18456fa6..714e79cd 100644 --- a/plugins/tasklist/localization/en_US.inc +++ b/plugins/tasklist/localization/en_US.inc @@ -1,73 +1,78 @@ <roundcube:object name="pagetitle" />

+
+ + +
\ No newline at end of file diff --git a/plugins/tasklist/skins/larry/templates/taskedit.html b/plugins/tasklist/skins/larry/templates/taskedit.html index 31ee79c2..97f604db 100644 --- a/plugins/tasklist/skins/larry/templates/taskedit.html +++ b/plugins/tasklist/skins/larry/templates/taskedit.html @@ -1,89 +1,93 @@


 
 
+ -
 %
+
+ + +
diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js index fcf43062..4bc82b0a 100644 --- a/plugins/tasklist/tasklist.js +++ b/plugins/tasklist/tasklist.js @@ -1,2079 +1,2089 @@ /** * Client scripts for the Tasklist plugin * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * 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 . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ function rcube_tasklist_ui(settings) { // extend base class rcube_libcalendaring.call(this, settings); /* constants */ var FILTER_MASK_ALL = 0; var FILTER_MASK_TODAY = 1; var FILTER_MASK_TOMORROW = 2; var FILTER_MASK_WEEK = 4; var FILTER_MASK_LATER = 8; var FILTER_MASK_NODATE = 16; var FILTER_MASK_OVERDUE = 32; var FILTER_MASK_FLAGGED = 64; var FILTER_MASK_COMPLETE = 128; var filter_masks = { all: FILTER_MASK_ALL, today: FILTER_MASK_TODAY, tomorrow: FILTER_MASK_TOMORROW, week: FILTER_MASK_WEEK, later: FILTER_MASK_LATER, nodate: FILTER_MASK_NODATE, overdue: FILTER_MASK_OVERDUE, flagged: FILTER_MASK_FLAGGED, complete: FILTER_MASK_COMPLETE }; /* private vars */ var selector = 'all'; var tagsfilter = []; var filtermask = FILTER_MASK_ALL; var loadstate = { filter:-1, lists:'', search:null }; var idcount = 0; var focusview; var saving_lock; var ui_loading; var taskcounts = {}; var listindex = []; var listdata = {}; var tags = []; var draghelper; var search_request; var search_query; var completeness_slider; var task_draghelper; var tag_draghelper; var task_drag_active = false; var list_scroll_top = 0; var scroll_delay = 400; var scroll_step = 5; var scroll_speed = 20; var scroll_sensitivity = 40; var scroll_timer; var me = this; // general datepicker settings var datepicker_settings = { // translate from PHP 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 }; var extended_datepicker_settings; /* public members */ this.tasklists = rcmail.env.tasklists; this.selected_task; this.selected_list; /* public methods */ this.init = init; this.edit_task = task_edit_dialog; this.delete_task = delete_task; this.add_childtask = add_childtask; this.quicksearch = quicksearch; this.reset_search = reset_search; this.expand_collapse = expand_collapse; this.list_remove = list_remove; this.list_edit_dialog = list_edit_dialog; this.unlock_saving = unlock_saving; /* imports */ var Q = this.quote_html; var text2html = this.text2html; var event_date_text = this.event_date_text; var parse_datetime = this.parse_datetime; var date2unixtime = this.date2unixtime; var fromunixtime = this.fromunixtime; /** * initialize the tasks UI */ function init() { // initialize task list selectors for (var id in me.tasklists) { if ((li = rcmail.get_folder_li(id, 'rcmlitasklist'))) { init_tasklist_li(li, id); } if (me.tasklists[id].editable && (!me.selected_list || (me.tasklists[id].active && !me.tasklists[me.selected_list].active))) { me.selected_list = id; } } if (me.selected_list) { rcmail.enable_command('addtask', true); $(rcmail.get_folder_li(me.selected_list, 'rcmlitasklist')).click(); } // register server callbacks rcmail.addEventListener('plugin.data_ready', data_ready); rcmail.addEventListener('plugin.update_task', update_taskitem); rcmail.addEventListener('plugin.refresh_tasks', function(p) { update_taskitem(p, true); }); rcmail.addEventListener('plugin.update_counts', update_counts); rcmail.addEventListener('plugin.insert_tasklist', insert_list); rcmail.addEventListener('plugin.update_tasklist', update_list); rcmail.addEventListener('plugin.destroy_tasklist', destroy_list); rcmail.addEventListener('plugin.unlock_saving', unlock_saving); rcmail.addEventListener('requestrefresh', before_refresh); rcmail.addEventListener('plugin.reload_data', function(){ list_tasks(null, true); setTimeout(fetch_counts, 200); }); // start loading tasks fetch_counts(); list_tasks(); // register event handlers for UI elements $('#taskselector a').click(function(e){ if (!$(this).parent().hasClass('inactive')) list_tasks(this.href.replace(/^.*#/, '')); return false; }); // quick-add a task $(rcmail.gui_objects.quickaddform).submit(function(e){ var tasktext = this.elements.text.value, rec = { id:-(++idcount), title:tasktext, readonly:true, mask:0, complete:0 }; if (tasktext && tasktext.length) { save_task({ tempid:rec.id, raw:tasktext, list:me.selected_list }, 'new'); render_task(rec); $('#listmessagebox').hide(); } // clear form this.reset(); return false; }).find('input[type=text]').placeholder(rcmail.gettext('createnewtask','tasklist')); // click-handler on tags list $(rcmail.gui_objects.tagslist).click(function(e){ var item = e.target.nodeName == 'LI' ? $(e.target) : $(e.target).closest('li'), tag = item.data('value'); if (!tag) return false; // reset selection on regular clicks var index = $.inArray(tag, tagsfilter); var shift = e.shiftKey || e.ctrlKey || e.metaKey; if (!shift) { if (tagsfilter.length > 1) index = -1; $('li', this).removeClass('selected'); tagsfilter = []; } // add tag to filter if (index < 0) { item.addClass('selected'); tagsfilter.push(tag); } else if (shift) { item.removeClass('selected'); var a = tagsfilter.slice(0,index); tagsfilter = a.concat(tagsfilter.slice(index+1)); } list_tasks(); // clear text selection in IE after shift+click if (shift && document.selection) document.selection.empty(); e.preventDefault(); return false; }) .mousedown(function(e){ // disable content selection with the mouse e.preventDefault(); return false; }); // click-handler on task list items (delegate) $(rcmail.gui_objects.resultlist).click(function(e){ var item = $(e.target); var className = e.target.className; if (item.hasClass('childtoggle')) { item = item.parent().find('.taskhead'); className = 'childtoggle'; } else if (!item.hasClass('taskhead')) item = item.closest('div.taskhead'); // ignore if (!item.length) return false; var id = item.data('id'), li = item.parent(), rec = listdata[id]; switch (className) { case 'childtoggle': rec.collapsed = !rec.collapsed; li.children('.childtasks:first').toggle(); $(e.target).toggleClass('collapsed').html(rec.collapsed ? '▶' : '▼'); rcmail.http_post('tasks/task', { action:'collapse', t:{ id:rec.id, list:rec.list }, collapsed:rec.collapsed?1:0 }); if (e.shiftKey) // expand/collapse all childs li.children('.childtasks:first .childtoggle.'+(rec.collapsed?'expanded':'collapsed')).click(); break; case 'complete': if (rcmail.busy) return false; - rec.complete = e.target.checked ? 1 : 0; + rec.status = e.target.checked ? 'COMPLETED' : (rec.complete == 1 ? 'NEEDS-ACTION' : ''); li.toggleClass('complete'); save_task(rec, 'edit'); return true; case 'flagged': if (rcmail.busy) return false; rec.flagged = rec.flagged ? 0 : 1; li.toggleClass('flagged'); save_task(rec, 'edit'); break; case 'date': if (rcmail.busy) return false; var link = $(e.target).html(''), input = $('').appendTo(link).val(rec.date || '') input.datepicker($.extend({ onClose: function(dateText, inst) { if (dateText != (rec.date || '')) { rec.date = dateText; save_task(rec, 'edit'); } input.datepicker('destroy').remove(); link.html(dateText || rcmail.gettext('nodate','tasklist')); } }, extended_datepicker_settings) ) .datepicker('setDate', rec.date) .datepicker('show'); break; case 'delete': delete_task(id); break; case 'actions': var pos, ref = $(e.target), menu = $('#taskitemmenu'); if (menu.is(':visible') && menu.data('refid') == id) { menu.hide(); } else { pos = ref.offset(); pos.left += ref.width() - menu.outerWidth(); pos.top += (pos.top + ref.outerHeight() + menu.height() > $(window).height() ? -menu.height() : ref.outerHeight()); menu.css({ top:pos.top+'px', left:pos.left+'px' }).show(); menu.data('refid', id); me.selected_task = rec; } e.bubble = false; break; case 'extlink': return true; default: if (e.target.nodeName != 'INPUT') task_show_dialog(id); break; } return false; }) .dblclick(function(e){ var id, rec, item = $(e.target); if (!item.hasClass('taskhead')) item = item.closest('div.taskhead'); if (!rcmail.busy && item.length && (id = item.data('id')) && (rec = listdata[id])) { var list = rec.list && me.tasklists[rec.list] ? me.tasklists[rec.list] : {}; if (rec.readonly || !list.editable) task_show_dialog(id); else task_edit_dialog(id, 'edit'); clearSelection(); } }); // handle global document clicks: close popup menus $(document.body).click(clear_popups); // extended datepicker settings var extended_datepicker_settings = $.extend({ showButtonPanel: true, beforeShow: function(input, inst) { setTimeout(function(){ $(input).datepicker('widget').find('button.ui-datepicker-close') .html(rcmail.gettext('nodate','tasklist')) .attr('onclick', '') .unbind('click') .bind('click', function(e){ $(input).datepicker('setDate', null).datepicker('hide'); }); }, 1); } }, datepicker_settings); } /** * initialize task edit form elements */ function init_taskedit() { $('#taskedit').tabs(); var completeness_slider_change = function(e, ui){ var v = completeness_slider.slider('value'); if (v >= 98) v = 100; if (v <= 2) v = 0; $('#taskedit-completeness').val(v); }; completeness_slider = $('#taskedit-completeness-slider').slider({ range: 'min', animate: 'fast', slide: completeness_slider_change, change: completeness_slider_change }); $('#taskedit-completeness').change(function(e){ completeness_slider.slider('value', parseInt(this.value)) }); // register events on alarms and recurrence fields me.init_alarms_edit('#taskedit-alarms'); me.init_recurrence_edit('#eventedit'); $('#taskedit-date, #taskedit-startdate').datepicker(datepicker_settings); $('a.edit-nodate').click(function(){ var sel = $(this).attr('rel'); if (sel) $(sel).val(''); return false; }); } /** * Request counts from the server */ function fetch_counts() { var active = active_lists(); if (active.length) rcmail.http_request('counts', { lists:active.join(',') }); else update_counts({}); } /** * List tasks matching the given selector */ function list_tasks(sel, force) { if (rcmail.busy) return; if (sel && filter_masks[sel] !== undefined) { filtermask = filter_masks[sel]; selector = sel; } var active = active_lists(), basefilter = filtermask == FILTER_MASK_COMPLETE ? FILTER_MASK_COMPLETE : FILTER_MASK_ALL, reload = force || active.join(',') != loadstate.lists || basefilter != loadstate.filter || loadstate.search != search_query; if (active.length && reload) { ui_loading = rcmail.set_busy(true, 'loading'); rcmail.http_request('fetch', { filter:basefilter, lists:active.join(','), q:search_query }, true); } else if (reload) data_ready({ data:[], lists:'', filter:basefilter, search:search_query }); else render_tasklist(); $('#taskselector li.selected').removeClass('selected'); $('#taskselector li.'+selector).addClass('selected'); } /** * Remove all tasks of the given list from the UI */ function remove_tasks(list_id) { // remove all tasks of the given list from index var newindex = $.grep(listindex, function(id, i){ return listdata[id] && listdata[id].list != list_id; }); listindex = newindex; render_tasklist(); // avoid reloading me.tasklists[list_id].active = false; loadstate.lists = active_lists(); } /** * Modify query parameters for refresh requests */ function before_refresh(query) { query.filter = filtermask == FILTER_MASK_COMPLETE ? FILTER_MASK_COMPLETE : FILTER_MASK_ALL; query.lists = active_lists().join(','); if (search_query) query.q = search_query; return query; } /** * Callback if task data from server is ready */ function data_ready(response) { listdata = {}; listindex = []; loadstate.lists = response.lists; loadstate.filter = response.filter; loadstate.search = response.search; for (var id, i=0; i < response.data.length; i++) { id = response.data[i].id; listindex.push(id); listdata[id] = response.data[i]; listdata[id].children = []; // register a forward-pointer to child tasks if (listdata[id].parent_id && listdata[listdata[id].parent_id]) listdata[listdata[id].parent_id].children.push(id); } append_tags(response.tags || []); render_tasklist(); rcmail.set_busy(false, 'loading', ui_loading); } /** * */ function render_tasklist() { // clear display var id, rec, count = 0, cache = {}, activetags = {}, msgbox = $('#listmessagebox').hide(), list = $(rcmail.gui_objects.resultlist).html(''); for (var i=0; i < listindex.length; i++) { id = listindex[i]; rec = listdata[id]; if (match_filter(rec, cache)) { render_task(rec); count++; // keep a list of tags from all visible tasks for (var t, j=0; rec.tags && j < rec.tags.length; j++) { t = rec.tags[j]; if (typeof activetags[t] == 'undefined') activetags[t] = 0; activetags[t]++; } } } fix_tree_toggles(); update_tagcloud(activetags); if (!count) msgbox.html(rcmail.gettext('notasksfound','tasklist')).show(); } /** * Show/hide child toggle buttons on all visible task items */ function fix_tree_toggles() { $('.taskitem', rcmail.gui_objects.resultlist).each(function(i,elem){ var li = $(elem), rec = listdata[li.attr('rel')], childs = $('.childtasks li', li); $('.childtoggle', li)[(childs.length ? 'show' : 'hide')](); }) } /** * Expand/collapse all task items with childs */ function expand_collapse(expand) { var collapsed = !expand; $('.taskitem .childtasks')[(collapsed ? 'hide' : 'show')](); $('.taskitem .childtoggle') .removeClass(collapsed ? 'expanded' : 'collapsed') .addClass(collapsed ? 'collapsed' : 'expanded') .html(collapsed ? '▶' : '▼'); // store new toggle collapse states var ids = []; for (var id in listdata) { if (listdata[id].children && listdata[id].children.length) ids.push(id); } if (ids.length) { rcmail.http_post('tasks/task', { action:'collapse', t:{ id:ids.join(',') }, collapsed:collapsed?1:0 }); } } /** * */ function append_tags(taglist) { // find new tags var newtags = []; for (var i=0; i < taglist.length; i++) { if ($.inArray(taglist[i], tags) < 0) newtags.push(taglist[i]); } tags = tags.concat(newtags); // append new tags to tag cloud $.each(newtags, function(i, tag){ $('
  • ').attr('rel', tag).data('value', tag) .html(Q(tag) + '') .appendTo(rcmail.gui_objects.tagslist) .draggable({ addClasses: false, revert: 'invalid', revertDuration: 300, helper: tag_draggable_helper, start: tag_draggable_start, appendTo: 'body', cursor: 'pointer' }); }); // re-sort tags list $(rcmail.gui_objects.tagslist).children('li').sortElements(function(a,b){ return $.text([a]).toLowerCase() > $.text([b]).toLowerCase() ? 1 : -1; }); } /** * Display the given counts to each tag and set those inactive which don't * have any matching tasks in the current view. */ function update_tagcloud(counts) { // compute counts first by iterating over all visible task items if (typeof counts == 'undefined') { counts = {}; $('li.taskitem', rcmail.gui_objects.resultlist).each(function(i,li){ var t, id = $(li).attr('rel'), rec = listdata[id]; for (var j=0; rec && rec.tags && j < rec.tags.length; j++) { t = rec.tags[j]; if (typeof counts[t] == 'undefined') counts[t] = 0; counts[t]++; } }); } $(rcmail.gui_objects.tagslist).children('li').each(function(i,li){ var elem = $(li), tag = elem.attr('rel'), count = counts[tag] || 0; elem.children('.count').html(count+''); if (count == 0) elem.addClass('inactive'); else elem.removeClass('inactive'); }); } /* Helper functions for drag & drop functionality of tags */ function tag_draggable_helper() { if (!tag_draghelper) tag_draghelper = $('
    '); else tag_draghelper.html(''); $(this).clone().addClass('tag').appendTo(tag_draghelper); return tag_draghelper; } function tag_draggable_start(event, ui) { $('.taskhead').droppable({ hoverClass: 'droptarget', accept: tag_droppable_accept, drop: tag_draggable_dropped, addClasses: false }); } function tag_droppable_accept(draggable) { if (rcmail.busy) return false; var tag = draggable.data('value'), drop_id = $(this).data('id'), drop_rec = listdata[drop_id]; // target already has this tag assigned if (!drop_rec || (drop_rec.tags && $.inArray(tag, drop_rec.tags) >= 0)) { return false; } return true; } function tag_draggable_dropped(event, ui) { var drop_id = $(this).data('id'), tag = ui.draggable.data('value'), rec = listdata[drop_id]; if (rec && rec.id) { if (!rec.tags) rec.tags = []; rec.tags.push(tag); save_task(rec, 'edit'); } } /** * */ function update_counts(counts) { // got new data if (counts) taskcounts = counts; // iterate over all selector links and update counts $('#taskselector a').each(function(i, elem){ var link = $(elem), f = link.parent().attr('class').replace(/\s\w+/, ''); if (f != 'all') link.children('span').html(taskcounts[f] || '')[(taskcounts[f] ? 'show' : 'hide')](); }); // spacial case: overdue $('#taskselector li.overdue')[(taskcounts.overdue ? 'removeClass' : 'addClass')]('inactive'); } /** * Callback from server to update a single task item */ function update_taskitem(rec, filter) { // handle a list of task records if ($.isArray(rec)) { $.each(rec, function(i,r){ update_taskitem(r, filter); }); return; } var id = rec.id, oldid = rec.tempid || id, oldrec = listdata[oldid], oldindex = $.inArray(oldid, listindex), oldparent = oldrec ? (oldrec._old_parent_id || oldrec.parent_id) : null, list = me.tasklists[rec.list]; if (oldindex >= 0) listindex[oldindex] = id; else listindex.push(id); listdata[id] = rec; // remove child-pointer from old parent if (oldparent && listdata[oldparent] && oldparent != rec.parent_id) { var oldchilds = listdata[oldparent].children, i = $.inArray(oldid, oldchilds); if (i >= 0) { listdata[oldparent].children = oldchilds.slice(0,i).concat(oldchilds.slice(i+1)); } } // register a forward-pointer to child tasks if (rec.parent_id && listdata[rec.parent_id] && listdata[rec.parent_id].children && $.inArray(id, listdata[rec.parent_id].children) < 0) listdata[rec.parent_id].children.push(id); // restore pointers to my children if (!listdata[id].children) { listdata[id].children = []; for (var pid in listdata) { if (listdata[pid].parent_id == id) listdata[id].children.push(pid); } } if (list.active || rec.tempid) { if (!filter || match_filter(rec, {})) render_task(rec, oldid); } else { $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).remove(); } append_tags(rec.tags || []); update_tagcloud(); fix_tree_toggles(); } /** * Submit the given (changed) task record to the server */ function save_task(rec, action) { if (!rcmail.busy) { saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); rcmail.http_post('tasks/task', { action:action, t:rec, filter:filtermask }); $('button.ui-button:ui-button').button('option', 'disabled', rcmail.busy); return true; } return false; } /** * Remove saving lock and free the UI for new input */ function unlock_saving() { if (saving_lock) { rcmail.set_busy(false, null, saving_lock); $('button.ui-button:ui-button').button('option', 'disabled', false); } } /** * Render the given task into the tasks list */ function render_task(rec, replace) { var tags_html = ''; for (var j=0; rec.tags && j < rec.tags.length; j++) tags_html += '' + Q(rec.tags[j]) + ''; var div = $('
    ').addClass('taskhead').html( '
    ' + - '' + + '' + '' + '' + text2html(Q(rec.title)) + '' + '' + tags_html + '' + '' + Q(rec.date || rcmail.gettext('nodate','tasklist')) + '' + 'V' ) .data('id', rec.id) .draggable({ revert: 'invalid', addClasses: false, cursorAt: { left:-10, top:12 }, helper: task_draggable_helper, appendTo: 'body', start: task_draggable_start, stop: task_draggable_stop, drag: task_draggable_move, revertDuration: 300 }); - if (rec.complete == 1.0) + if (is_complete(rec)) div.addClass('complete'); if (rec.flagged) div.addClass('flagged'); if (!rec.date) div.addClass('nodate'); if ((rec.mask & FILTER_MASK_OVERDUE)) div.addClass('overdue'); var li, inplace = false, parent = rec.parent_id ? $('li[rel="'+rec.parent_id+'"] > ul.childtasks', rcmail.gui_objects.resultlist) : null; if (replace && (li = $('li[rel="'+replace+'"]', rcmail.gui_objects.resultlist)) && li.length) { li.children('div.taskhead').first().replaceWith(div); li.attr('rel', rec.id); inplace = true; } else { li = $('
  • ') .attr('rel', rec.id) .addClass('taskitem') .append((rec.collapsed ? '