diff --git a/plugins/tasklist/.gitignore b/plugins/tasklist/.gitignore new file mode 100644 index 00000000..2b9ce0a5 --- /dev/null +++ b/plugins/tasklist/.gitignore @@ -0,0 +1 @@ +config.inc.php \ No newline at end of file diff --git a/plugins/tasklist/config.inc.php.dist b/plugins/tasklist/config.inc.php.dist new file mode 100644 index 00000000..2fc5e282 --- /dev/null +++ b/plugins/tasklist/config.inc.php.dist @@ -0,0 +1,4 @@ + + * + * 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 +{ + public $undelete = true; // yes, we can + public $sortable = false; + + private $rc; + private $plugin; + private $cache = array(); + private $lists = array(); + private $list_ids = ''; + + private $db_tasks = 'tasks'; + private $db_lists = 'tasklists'; + private $sequence_tasks = 'task_ids'; + private $sequence_lists = 'tasklist_ids'; + + + /** + * Default constructor + */ + public function __construct($plugin) + { + $this->rc = $plugin->rc; + $this->plugin = $plugin; + + // read database config + $this->db_lists = $this->rc->config->get('db_table_lists', $this->db_lists); + $this->db_tasks = $this->rc->config->get('db_table_tasks', $this->db_tasks); + $this->sequence_lists = $this->rc->config->get('db_sequence_lists', $this->sequence_lists); + $this->sequence_tasks = $this->rc->config->get('db_sequence_tasks', $this->sequence_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 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); + $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)) { + if ($this->create_list(array('name' => 'Default', 'color' => '000000'))) + $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, + $prop['name'], + $prop['color'], + $prop['showalarms']?1:0 + ); + + if ($result) + return $this->rc->db->insert_id($this->sequence_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 calendar_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) + { + // TODO: implement this + 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", + 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'; + else // don't show complete tasks by default + $sql_add .= ' AND complete<1'; + + 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) . ')'; + } + + $tasks = array(); + if (!empty($list_ids)) { + $datecol = $this->rc->db->quote_identifier('date'); + $timecol = $this->rc->db->quote_identifier('time'); + $result = $this->rc->db->query(sprintf( + "SELECT * FROM " . $this->db_tasks . " + WHERE tasklist_id IN (%s) + AND del=0 + %s", + 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; + } + + /** + * 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'] = strtotime($rec['changed']); + + if (!$rec['parent_id']) + unset($rec['parent_id']); + + 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; + + foreach (array('parent_id', 'date', 'time') as $col) { + if (empty($prop[$col])) + $prop[$col] = null; + } + + $result = $this->rc->db->query(sprintf( + "INSERT INTO " . $this->db_tasks . " + (tasklist_id, uid, parent_id, created, changed, title, date, time) + VALUES (?, ?, ?, %s, %s, ?, ?, ?)", + $this->rc->db->now(), + $this->rc->db->now() + ), + $list_id, + $prop['uid'], + $prop['parent_id'], + $prop['title'], + $prop['date'], + $prop['time'] + ); + + if ($result) + return $this->rc->db->insert_id($this->sequence_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) + { + $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') as $col) { + if (isset($prop[$col])) + $sql_set[] = $this->rc->db->quote_identifier($col) . '=' . (empty($prop[$col]) ? 'NULL' : $this->rc->db->quote($prop[$col])); + } + + $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); + } + + /** + * 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); + } + +} diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php new file mode 100644 index 00000000..5ccb3de9 --- /dev/null +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -0,0 +1,422 @@ + + * + * 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 = false; + public $undelete = false; // task undelete action + + 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(); + } + + /** + * Read available calendars for the current user and store them internally + */ + private function _read_lists() + { + // already read sources + if (isset($this->lists)) + return $this->lists; + + // get all folders that have type "task" + $this->folders = kolab_storage::get_folders('task'); + $this->lists = array(); + + // convert to UTF8 and sort + $names = array(); + foreach ($this->folders as $i => $folder) { + $names[$folder->name] = rcube_charset::convert($folder->name, 'UTF7-IMAP'); + $this->folders[$folder->name] = $folder; + } + + asort($names, SORT_LOCALE_STRING); + + foreach ($names as $utf7name => $name) { + $folder = $this->folders[$utf7name]; + $tasklist = array( + 'id' => kolab_storage::folder_id($utf7name), + 'name' => kolab_storage::object_name($utf7name), + 'color' => 'CC0000', + 'showalarms' => false, + 'active' => 1, #$folder->is_subscribed(kolab_storage::SERVERSIDE_SUBSCRIPTION), + ); + $this->lists[$tasklist['id']] = $tasklist; + $this->folders[$tasklist['id']] = $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' => 'Default', 'color' => '000000'))) + $this->_read_lists(); + } + + 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) + { + return false; + } + + /** + * 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) + { + 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) + { + 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) + { + 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 ((array)$folder->select(array(array('tags','!~','complete'))) as $record) { + $rec = $this->_to_rcube_task($record); + + if ($rec['complete']) // 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','~','complete'); + else + $query[] = array('tags','!~','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); + } + } + + foreach ($lists as $list_id) { + $folder = $this->folders[$list_id]; + foreach ((array)$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; + $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 $folder) { + if (!$this->tasks[$id] && ($object = $folder->get_object($id))) { + $this->tasks[$id] = $this->_to_rcube_task($object); + break; + } + } + + return $this->tasks[$id]; + } + + /** + * 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'], + 'flagged' => $record['priority'] == 1, + 'complete' => $record['status'] == 'COMPLETED' ? 1 : floatval($record['complete'] / 100), + 'parent_id' => $record['parent_id'], + ); + + // convert from DateTime to internal date format + if (is_a($record['due'], 'DateTime')) { + $task['date'] = $record['due']->format('Y-m-d'); + $task['time'] = $record['due']->format('h:i'); + } + if (is_a($record['dtstamp'], 'DateTime')) { + $task['changed'] = $record['dtstamp']->format('U'); + } + + 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; + + 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']); + } + + $object['complete'] = $task['complete'] * 100; + if ($task['complete'] == 1.0) + $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; + } + + unset($object['tempid'], $object['raw']); + 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['uid'], $folder->name)) + return false; + + unset($task['_fromlist']); + } + + // load previous version of this task to merge + if ($task['id']) { + $old = $folder->get_object($task['uid']); + if (!$old || PEAR::isError($old)) + return false; + } + + // 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['id'] = $task['uid']; + $this->tasks[$task['uid']] = $task; + } + + return $saved; + } + + /** + * 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['uid']); + } + + /** + * 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; + } + + +} diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php new file mode 100644 index 00000000..a3b0a850 --- /dev/null +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -0,0 +1,184 @@ + + * + * 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 of record + * 'title' => 'Event title/summary', + * 'description' => 'Event description', + * '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 + * '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) + * 'alarms' => '-15M:DISPLAY', // Reminder settings inspired by valarm definition (e.g. display alert 15 minutes before due time) + * '_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 $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); + + /** + * 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); + + /** + * 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); + + /** + * 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; + } + + /** + * List availabale categories + * The default implementation reads them from config/user prefs + */ + public function list_categories() + { + $rcmail = rcmail::get_instance(); + return $rcmail->config->get('tasklist_categories', array()); + } + +} diff --git a/plugins/tasklist/localization/de_CH.inc b/plugins/tasklist/localization/de_CH.inc new file mode 100644 index 00000000..a7b3712f --- /dev/null +++ b/plugins/tasklist/localization/de_CH.inc @@ -0,0 +1,43 @@ + + * Screendesign by FLINT / Büro für Gestaltung, bueroflint.com + * + * The contents are subject to the Creative Commons Attribution-ShareAlike + * License. It is allowed to copy, distribute, transmit and to adapt the work + * by keeping credits to the original autors in the README file. + * See http://creativecommons.org/licenses/by-sa/3.0/ for details. + * + * $Id$ + */ + +#taskbar a.button-tasklist span.button-inner { + background-image: url(taskbaricon.png); + background-position: 0 0; +} + +#taskbar a.button-tasklist:hover span.button-inner, +#taskbar a.button-tasklist.button-selected span.button-inner { + background-position: 0 -44px; +} + +#sidebar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 240px; +} + +body.tasklistview #searchmenulink { + width: 15px; +} + +#selectorbox { + position: absolute; + top: 42px; + left: 0; + width: 100%; + height: 242px; +} + +#tasklistsbox { + position: absolute; + top: 300px; + left: 0; + width: 100%; + bottom: 0px; +} + +#taskselector li { + position: relative; +} + +#taskselector li:first-child { + border-top: 0; + border-radius: 4px 4px 0 0; +} + +#taskselector li:last-child { + border-bottom: 0; + border-radius: 0 0 4px 4px; +} + +#taskselector li.selected { + background-color: #c7e3ef; +} + +#taskselector li.overdue a { + color: #b72a2a; + font-weight: bold; +} + +#taskselector li.inactive a { + color: #97b3bf; +} + +#taskselector li .count { + display: none; + position: absolute; + top: 3px; + right: 6px; + min-width: 1.8em; + padding: 2px 4px; + background: #d9ecf4; + background: -moz-linear-gradient(top, #d9ecf4 0%, #c7e3ef 100%); + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#d9ecf4), color-stop(100%,#c7e3ef)); + background: -o-linear-gradient(top, #d9ecf4 0%, #c7e3ef 100%); + background: -ms-linear-gradient(top, #d9ecf4 0%, #c7e3ef 100%); + background: linear-gradient(top, #d9ecf4 0%, #c7e3ef 100%); + box-shadow: inset 0 1px 1px 0 #b7d3df; + -o-box-shadow: inset 0 1px 1px 0 #b7d3df; + -webkit-box-shadow: inset 0 1px 1px 0 #b7d3df; + -moz-box-shadow: inset 0 1px 1px 0 #b7d3df; + border: 1px solid #a7c3cf; + border-radius: 9px; + color: #69939e; + text-align: center; + font-weight: bold; + text-shadow: none; +} + +#taskselector li.selected .count { + color: #fff; + background: #005d76; + background: -moz-linear-gradient(top, #005d76 0%, #004558 100%); + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#005d76), color-stop(100%,#004558)); + background: -o-linear-gradient(top, #005d76 0%, #004558 100%); + background: -ms-linear-gradient(top, #005d76 0%, #004558 100%); + background: linear-gradient(top, #005d76 0%, #004558 100%); + box-shadow: inset 0 1px 1px 0 #003645; + -o-box-shadow: inset 0 1px 1px 0 #003645; + -webkit-box-shadow: inset 0 1px 1px 0 #003645; + -moz-box-shadow: inset 0 1px 1px 0 #003645; + border-color: #003645; +} + +#taskselector li.overdue.selected .count { + background: #db3333; + background: -moz-linear-gradient(top, #db3333 0%, #a82727 100%); + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#db3333), color-stop(100%,#a82727)); + background: -o-linear-gradient(top, #db3333 0%, #a82727 100%); + background: -ms-linear-gradient(top, #db3333 0%, #a82727 100%); + background: linear-gradient(top, #db3333 0%, #a82727 100%); + box-shadow: inset 0 1px 1px 0 #831f1f; + -o-box-shadow: inset 0 1px 1px 0 #831f1f; + -webkit-box-shadow: inset 0 1px 1px 0 #831f1f; + -moz-box-shadow: inset 0 1px 1px 0 #831f1f; + border-color: #831f1f; +} + +#tasklists li { + margin: 0; + height: 20px; + padding: 6px 8px 2px; + display: block; + position: relative; + white-space: nowrap; +} + +#tasklists li label { + display: block; +} + +#tasklists li span.listname { + cursor: default; + padding-bottom: 2px; + color: #004458; +} + +#tasklists li span.handle { + display: none; +} + +#tasklists li input { + position: absolute; + top: 5px; + right: 5px; +} + +#tasklists li.selected { + background-color: #c7e3ef; +} + +#tasklists li.selected span.calname { + font-weight: bold; +} + +#mainview-right { + position: absolute; + top: 0; + left: 256px; + right: 0; + bottom: 0; +} + +#taskstoolbar { + position: absolute; + top: -6px; + right: 0; + width: 40%; + height: 40px; + white-space: nowrap; + text-align: right; +} + +#quickaddbox { + position: absolute; + top: 0; + left: 0; + width: 60%; + height: 32px; + white-space: nowrap; +} + +#quickaddinput { + width: 85%; + margin: 0; + padding: 5px 8px; + background: #f1f1f1; + background: rgba(255, 255, 255, 0.7); + border-color: #a3a3a3; + font-weight: bold; +} + +#quickaddbox .button { + margin-left: 5px; + padding: 3px 10px; + font-weight: bold; +} + +#tasksview { + position: absolute; + top: 42px; + left: 0; + right: 0; + bottom: 0; + padding-bottom: 28px; + background: rgba(255, 255, 255, 0.3); +} + +#message.statusbar { + border-top: 1px solid #c3c3c3; +} + +#tasksview .scroller { + position: absolute; + top: 0; + left: 0; + width: 100%; + bottom: 28px; + overflow: auto; +} + +#thelist { + padding: 0; + margin: 1em; + list-style: none; +} + +#listmessagebox { + display: none; + font-size: 14px; + color: #666; + margin: 1.5em; + text-shadow: 0px 1px 1px #fff; + text-align:center; +} + +.taskitem { + display: block; + margin-bottom: 5px; +} + +.taskitem.dragging { + opacity: 0.5; +} + +.taskitem .childtasks { + padding: 0; + margin: 0.5em 0 0 2em; + list-style: none; +} + +.taskhead { + position: relative; + padding: 4px 5px 3px 5px; + border: 1px solid #fff; + border-radius: 5px; + background: #fff; + -webkit-box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.5); + -moz-box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.5); + box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.5); + padding-right: 11em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; +} + +.taskhead.droptarget { + border-color: #4787b1; + box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); + -moz-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); + -webkit-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); + -o-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); +} + +.taskhead .complete { + margin: -1px 1em 0 0; +} + +.taskhead .title { + font-size: 12px; +} + +.taskhead .flagged { + display: inline-block; + visibility: hidden; + width: 16px; + height: 16px; + background: url(sprites.png) -2px -3px no-repeat; + margin: -3px 1em 0 0; + vertical-align: middle; + cursor: pointer; +} + +.taskhead:hover .flagged { + visibility: visible; +} + +.taskhead.flagged .flagged { + visibility: visible; + background-position: -2px -23px; +} + +.taskhead .date { + position: absolute; + top: 6px; + right: 30px; + text-align: right; + cursor: pointer; +} + +.taskhead.nodate .date { + color: #ddd; +} + +.taskhead.overdue .date { + color: #d00; +} + +.taskhead.nodate:hover .date { + color: #999; +} + +.taskhead .date input { + padding: 1px 2px; + border: 1px solid #ddd; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + outline: none; + text-align: right; +} + +.taskhead .actions, +.taskhead .delete { + display: block; + visibility: hidden; + position: absolute; + top: 3px; + right: 6px; + width: 18px; + height: 18px; + background: url(sprites.png) 0 -80px no-repeat; + text-indent: -1000px; + overflow: hidden; + cursor: pointer; +} + +.taskhead .delete { + background-position: 0 -40px; +} + +.taskhead:hover .actions, +.taskhead:hover .delete { + visibility: visible; +} + +.taskhead.complete { + opacity: 0.6; +} + +.taskhead.complete .title { + text-decoration: line-through; +} + +.taskhead .progressbar { + position: absolute; + bottom: 1px; + left: 6px; + right: 6px; + height: 2px; +} + +.taskhead.complete .progressbar { + display: none; +} + +.taskhead .progressvalue { + height: 1px; + background: rgba(1, 124, 180, 0.2); + border-top: 1px solid #219de6; +} + +ul.toolbarmenu li span.add { + background-image: url(sprites.png); + background-position: 0 -100px; +} + +ul.toolbarmenu li span.delete { + background-position: 0 -1508px; +} + +.taskitem-draghelper { +/* + width: 32px; + height: 26px; +*/ + background: #444; + border: 1px solid #555; + border-radius: 4px; + box-shadow: 0 2px 6px 0 #333; + -moz-box-shadow: 0 2px 6px 0 #333; + -webkit-box-shadow: 0 2px 6px 0 #333; + -o-box-shadow: 0 2px 6px 0 #333; + z-index: 5000; + padding: 2px 10px; + font-size: 20px; + color: #ccc; + opacity: 0.92; + filter: alpha(opacity=92); + text-shadow: 0px 1px 1px #333; +} + +#rootdroppable { + display: none; + position: absolute; + top: 3px; + left: 1em; + right: 1em; + height: 5px; + background: #ddd; + border-radius: 3px; +} + +#rootdroppable.droptarget { + background: #4787b1; + box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); + -moz-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); + -webkit-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); + -o-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); + +} + +/*** task edit form ***/ + +#taskedit, +#taskshow { + display:none; +} + +#taskshow h2 { + margin-top: -0.5em; +} + +#taskshow label { + color: #999; +} + +a.morelink { + font-size: 90%; + color: #0069a6; + text-decoration: none; + outline: none; +} + +a.morelink:hover { + text-decoration: underline; +} + +#taskeditform input.text, +#taskeditform textarea { + width: 97%; +} + +div.form-section { + position: relative; + margin-top: 0.2em; + margin-bottom: 0.8em; +} + +.form-section label { + display: inline-block; + min-width: 7em; + padding-right: 0.5em; +} + +label.block { + display: block; + margin-bottom: 0.3em; +} + +#edit-completeness-slider { + display: inline-block; + margin-left: 2em; + width: 30em; + height: 0.8em; + border: 1px solid #ccc; +} + diff --git a/plugins/tasklist/skins/larry/templates/mainview.html b/plugins/tasklist/skins/larry/templates/mainview.html new file mode 100644 index 00000000..fd8fa5c2 --- /dev/null +++ b/plugins/tasklist/skins/larry/templates/mainview.html @@ -0,0 +1,141 @@ + + + +<roundcube:object name="pagetitle" /> + + + + + + +
+ + +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+
+ +
+ +
+ +
+ +
+
    +
  • +
  • +
  • +
+
+ +
+
+

+
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+ +   + + +
+
+ +  % +
+
+
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js new file mode 100644 index 00000000..c76e4112 --- /dev/null +++ b/plugins/tasklist/tasklist.js @@ -0,0 +1,872 @@ +/** + * Client scripts for the Tasklist plugin + * + * @version @package_version@ + * @author Thomas Bruederli + * + * 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 . + */ + +function rcube_tasklist(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 filtermask = FILTER_MASK_ALL; + var idcount = 0; + var selected_list; + var saving_lock; + var ui_loading; + var taskcounts = {}; + var listdata = {}; + var draghelper; + var completeness_slider; + var search_request; + var search_query; + var me = this; + + // general datepicker settings + var datepicker_settings = { + // translate from PHP format to datepicker format + dateFormat: settings['date_format'].replace(/m/, 'mm').replace(/n/g, 'm').replace(/F/, 'MM').replace(/l/, 'DD').replace(/dd/, 'D').replace(/d/, 'dd').replace(/j/, 'd').replace(/Y/g, 'yy'), + 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; + + /* 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; + + + /** + * initialize the tasks UI + */ + function init() + { + // select the first task list + for (var s in me.tasklists) { + selected_list = s; + break; + }; + + // register server callbacks + rcmail.addEventListener('plugin.data_ready', data_ready); + rcmail.addEventListener('plugin.refresh_task', update_taskitem); + rcmail.addEventListener('plugin.update_counts', update_counts); + rcmail.addEventListener('plugin.reload_data', function(){ list_tasks(null); }); + rcmail.addEventListener('plugin.unlock_saving', function(p){ rcmail.set_busy(false, null, saving_lock); }); + + // 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; + var rec = { id:-(++idcount), title:tasktext, readonly:true, mask:0, complete:0 }; + + save_task({ tempid:rec.id, raw:tasktext, list:selected_list }, 'new'); + render_task(rec); + + // clear form + this.reset(); + return false; + }); + + // click-handler on task list items (delegate) + $(rcmail.gui_objects.resultlist).click(function(e){ + var item = $(e.target); + + if (!item.hasClass('taskhead')) + item = item.closest('div.taskhead'); + + // ignore + if (!item.length) + return; + + var id = item.data('id'), + li = item.parent(), + rec = listdata[id]; + + switch (e.target.className) { + case 'complete': + rec.complete = e.target.checked ? 1 : 0; + li.toggleClass('complete'); + save_task(rec, 'edit'); + return true; + + case 'flagged': + rec.flagged = rec.flagged ? 0 : 1; + li.toggleClass('flagged'); + save_task(rec, 'edit'); + break; + + case 'date': + 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.top += ref.outerHeight(); + pos.left += ref.width() - menu.outerWidth(); + menu.css({ top:pos.top+'px', left:pos.left+'px' }).show(); + menu.data('refid', id); + me.selected_task = rec; + } + e.bubble = false; + break; + + 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 (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.readonly) + task_show_dialog(id); + else + task_edit_dialog(id, 'edit'); + clearSelection(); + } + }); + + completeness_slider = $('#edit-completeness-slider').slider({ + range: 'min', + slide: function(e, ui){ + var v = completeness_slider.slider('value'); + if (v >= 98) v = 100; + if (v <= 2) v = 0; + $('#edit-completeness').val(v); + } + }); + $('#edit-completeness').change(function(e){ completeness_slider.slider('value', parseInt(this.value)) }); + + // handle global document clicks: close popup menus + $(document.body).click(clear_popups); + + // extended datepicker settings + 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', '') + .click(function(e){ + $(input).datepicker('setDate', null).datepicker('hide'); + }); + }, 1); + }, + }, datepicker_settings); + } + + /** + * Request counts from the server + */ + function fetch_counts() + { + rcmail.http_request('counts'); + } + + /** + * fetch tasks from server + */ + function list_tasks(sel) + { + if (sel && filter_masks[sel] !== undefined) { + filtermask = filter_masks[sel]; + selector = sel; + } + + ui_loading = rcmail.set_busy(true, 'loading'); + rcmail.http_request('fetch', { filter:filtermask, q:search_query }, true); + + $('#taskselector li.selected').removeClass('selected'); + $('#taskselector li.'+selector).addClass('selected'); + } + + /** + * callback if task data from server is ready + */ + function data_ready(data) + { + // clear display + var msgbox = $('#listmessagebox').hide(), + list = $(rcmail.gui_objects.resultlist).html(''); + listdata = {}; + + for (var i=0; i < data.length; i++) { + listdata[data[i].id] = data[i]; + render_task(data[i]); + } + + if (!data.length) + msgbox.html(rcmail.gettext('notasksfound','tasklist')).show(); + + rcmail.set_busy(false, 'loading', ui_loading); + } + + /** + * + */ + 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+/, ''); + 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) + { + var id = rec.id; + listdata[id] = rec; + render_task(rec, rec.tempid || id); + } + + /** + * 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('task', { action:action, t:rec, filter:filtermask }); + return true; + } + + return false; + } + + /** + * Render the given task into the tasks list + */ + function render_task(rec, replace) + { + var div = $('
').addClass('taskhead').html( + '
' + + '' + + '' + + '' + Q(rec.title) + '' + + '' + Q(rec.date || rcmail.gettext('nodate','tasklist')) + '' + + 'V' + ) + .data('id', rec.id) + .draggable({ + revert: 'invalid', + addClasses: false, + cursorAt: { left:-10, top:12 }, + helper: draggable_helper, + appendTo: 'body', + start: draggable_start, + stop: draggable_stop, + revertDuration: 300 + }); + + if (rec.complete == 1.0) + div.addClass('complete'); + if (rec.flagged) + div.addClass('flagged'); + if (!rec.date) + div.addClass('nodate'); + if ((rec.mask & FILTER_MASK_OVERDUE)) + div.addClass('overdue'); +console.log(replace) + var li, parent; + if (replace && (li = $('li[rel="'+replace+'"]', rcmail.gui_objects.resultlist)) && li.length) { + li.children('div.taskhead').first().replaceWith(div); + li.attr('rel', rec.id); + } + else { + li = $('
  • ') + .attr('rel', rec.id) + .addClass('taskitem') + .append(div) + .append('
      '); + + if (rec.parent_id && (parent = $('li[rel="'+rec.parent_id+'"] > ul.childtasks', rcmail.gui_objects.resultlist)) && parent.length) + li.appendTo(parent); + else + li.appendTo(rcmail.gui_objects.resultlist); + } + + if (replace) { + resort_task(rec, li, true); + // TODO: remove the item after a while if it doesn't match the current filter anymore + } + } + + /** + * Move the given task item to the right place in the list + */ + function resort_task(rec, li, animated) + { + var dir = 0, next_li, next_id, next_rec; + + // animated moving + var insert_animated = function(li, before, after) { + if (before && li.next().get(0) == before.get(0)) + return; // nothing to do + else if (after && li.prev().get(0) == after.get(0)) + return; // nothing to do + + var speed = 300; + li.slideUp(speed, function(){ + if (before) li.insertBefore(before); + else if (after) li.insertAfter(after); + li.slideDown(speed); + }); + } + + // find the right place to insert the task item + li.siblings().each(function(i, elem){ + next_li = $(elem); + next_id = next_li.attr('rel'); + next_rec = listdata[next_id]; + + if (next_id == rec.id) { + next_li = null; + return 1; // continue + } + + if (next_rec && task_cmp(rec, next_rec) > 0) { + return 1; // continue; + } + else if (next_rec && next_li && task_cmp(rec, next_rec) < 0) { + if (animated) insert_animated(li, next_li); + else li.insertBefore(next_li) + next_li = null; + return false; + } + }); + + if (next_li) { + if (animated) insert_animated(li, null, next_li); + else li.insertAfter(next_li); + } + return; + } + + /** + * Compare function of two task records. + * (used for sorting) + */ + function task_cmp(a, b) + { + var d = Math.floor(a.complete) - Math.floor(b.complete); + if (!d) d = (b._hasdate-0) - (a._hasdate-0); + if (!d) d = (a.datetime||99999999999) - (b.datetime||99999999999); + return d; + } + + + /* Helper functions for drag & drop functionality */ + + function draggable_helper() + { + if (!draghelper) + draghelper = $('
      '); + + return draghelper; + } + + function draggable_start(event, ui) + { + $('.taskhead, #rootdroppable').droppable({ + hoverClass: 'droptarget', + accept: droppable_accept, + drop: draggable_dropped, + addClasses: false + }); + + $(this).parent().addClass('dragging'); + $('#rootdroppable').show(); + } + + function draggable_stop(event, ui) + { + $(this).parent().removeClass('dragging'); + $('#rootdroppable').hide(); + } + + function droppable_accept(draggable) + { + var drag_id = draggable.data('id'), + parent_id = $(this).data('id'), + rec = listdata[parent_id]; + + if (parent_id == listdata[drag_id].parent_id) + return false; + + while (rec && rec.parent_id) { + if (rec.parent_id == drag_id) + return false; + rec = listdata[rec.parent_id]; + } + + return true; + } + + function draggable_dropped(event, ui) + { + var parent_id = $(this).data('id'), + task_id = ui.draggable.data('id'), + parent = parent_id ? $('li[rel="'+parent_id+'"] > ul.childtasks', rcmail.gui_objects.resultlist) : $(rcmail.gui_objects.resultlist), + rec = listdata[task_id], + li; + + if (rec && parent.length) { + // submit changes to server + rec.parent_id = parent_id || 0; + save_task(rec, 'edit'); + + li = ui.draggable.parent(); + li.slideUp(300, function(){ + li.appendTo(parent); + resort_task(rec, li); + li.slideDown(300); + }); + } + } + + + /** + * Show task details in a dialog + */ + function task_show_dialog(id) + { + var $dialog = $('#taskshow').dialog('close'), rec;; + + if (!(rec = listdata[id]) || clear_popups({})) + return; + + me.selected_task = rec; + + // fill dialog data + $('#task-title').html(Q(rec.title || '')); + $('#task-description').html(text2html(rec.description || '', 300, 6))[(rec.description ? 'show' : 'hide')](); + $('#task-date')[(rec.date ? 'show' : 'hide')]().children('.task-text').html(Q(rec.date || rcmail.gettext('nodate','tasklist'))); + $('#task-time').html(Q(rec.time || '')); + $('#task-completeness .task-text').html(((rec.complete || 0) * 100) + '%'); + $('#task-list .task-text').html(Q(me.tasklists[rec.list] ? me.tasklists[rec.list].name : '')); + + // define dialog buttons + var buttons = {}; + buttons[rcmail.gettext('edit','tasklist')] = function() { + task_edit_dialog(me.selected_task.id, 'edit'); + $dialog.dialog('close'); + }; + + buttons[rcmail.gettext('delete','tasklist')] = function() { + if (delete_task(me.selected_task.id)) + $dialog.dialog('close'); + }; + + // open jquery UI dialog + $dialog.dialog({ + modal: false, + resizable: true, + closeOnEscape: true, + title: rcmail.gettext('taskdetails', 'tasklist'), + close: function() { + $dialog.dialog('destroy').appendTo(document.body); + }, + buttons: buttons, + minWidth: 500, + width: 580 + }).show(); + } + + /** + * Opens the dialog to edit a task + */ + function task_edit_dialog(id, action, presets) + { + $('#taskshow').dialog('close'); + + var rec = listdata[id] || presets, + $dialog = $('
      '), + editform = $('#taskedit'), + list = rec.list && me.tasklists[rec.list] ? me.tasklists[rec.list] : + (selected_list ? me.tasklists[selected_list] : { editable: action=='new' }); + + if (list.readonly || (action == 'edit' && (!rec || rec.readonly || rec.temp))) + return false; + + me.selected_task = $.extend({}, rec); // clone task object + + // fill form data + var title = $('#edit-title').val(rec.title || ''); + var description = $('#edit-description').val(rec.description || ''); + var recdate = $('#edit-date').val(rec.date || '').datepicker(datepicker_settings); + var rectime = $('#edit-time').val(rec.time || ''); + var complete = $('#edit-completeness').val((rec.complete || 0) * 100); + completeness_slider.slider('value', complete.val()); + var tasklist = $('#edit-tasklist').val(rec.list || 0); + + $('#edit-nodate').unbind('click').click(function(){ + recdate.val(''); + rectime.val(''); + return false; + }) + + // define dialog buttons + var buttons = {}; + buttons[rcmail.gettext('save', 'tasklist')] = function() { + me.selected_task.title = title.val(); + me.selected_task.description = description.val(); + me.selected_task.date = recdate.val(); + me.selected_task.time = rectime.val(); + me.selected_task.list = tasklist.val(); + + if (me.selected_task.list && me.selected_task.list != rec.list) + me.selected_task._fromlist = rec.list; + + me.selected_task.complete = complete.val() / 100; + if (isNaN(me.selected_task.complete)) + me.selected_task.complete = null; + + if (!me.selected_task.list && list.id) + me.selected_task.list = list.id; + + if (save_task(me.selected_task, action)) + $dialog.dialog('close'); + }; + + if (rec.id) { + buttons[rcmail.gettext('delete', 'tasklist')] = function() { + if (delete_task(rec.id)) + $dialog.dialog('close'); + }; + } + + buttons[rcmail.gettext('cancel', 'tasklist')] = function() { + $dialog.dialog('close'); + }; + + // open jquery UI dialog + $dialog.dialog({ + modal: true, + resizable: (!bw.ie6 && !bw.ie7), // disable for performance reasons + closeOnEscape: false, + title: rcmail.gettext((action == 'edit' ? 'edittask' : 'newtask'), 'tasklist'), + close: function() { + editform.hide().appendTo(document.body); + $dialog.dialog('destroy').remove(); + }, + buttons: buttons, + minWidth: 500, + width: 580 + }).append(editform.show()); // adding form content AFTERWARDS massively speeds up opening on IE6 + + title.select(); + } + + /** + * + */ + function add_childtask(id) + { + task_edit_dialog(null, 'new', { parent_id:id }); + } + + /** + * Delete the given task + */ + function delete_task(id) + { + var rec = listdata[id]; + if (rec && confirm("Delete this?")) { + saving_lock = rcmail.set_busy(true, 'tasklist.savingdata'); + rcmail.http_post('task', { action:'delete', t:rec, filter:filtermask }); + $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).hide(); + return true; + } + + return false; + } + + /** + * Check if the given task matches the current filtermask + */ + function match_filter(rec) + { + // TBD. + return true; + } + + /** + * Execute search + */ + function quicksearch() + { + var q; + if (rcmail.gui_objects.qsearchbox && (q = rcmail.gui_objects.qsearchbox.value)) { + var id = 'search-'+q; + var resources = []; + + for (var rid in me.tasklists) { + if (me.tasklists[rid].active) { + resources.push(rid); + } + } + id += '@'+resources.join(','); + + // ignore if query didn't change + if (search_request == id) + return; + + search_request = id; + search_query = q; + + list_tasks('all'); + } + else // empty search input equals reset + this.reset_search(); + } + + /** + * Reset search and get back to normal listing + */ + function reset_search() + { + $(rcmail.gui_objects.qsearchbox).val(''); + + if (search_request) { + search_request = search_query = null; + list_tasks(); + } + } + + + /**** Utility functions ****/ + + /** + * quote html entities + */ + function Q(str) + { + return String(str).replace(//g, '>').replace(/"/g, '"'); + } + + /** + * Name says it all + * (cloned from calendar plugin) + */ + function text2html(str, maxlen, maxlines) + { + var html = Q(String(str)); + + // limit visible text length + if (maxlen) { + var morelink = ' '+rcmail.gettext('showmore','tasklist')+'', + lines = html.split(/\r?\n/), + words, out = '', len = 0; + + for (var i=0; i < lines.length; i++) { + len += lines[i].length; + if (maxlines && i == maxlines - 1) { + out += lines[i] + '\n' + morelink; + maxlen = html.length * 2; + } + else if (len > maxlen) { + len = out.length; + words = lines[i].split(' '); + for (var j=0; j < words.length; j++) { + len += words[j].length + 1; + out += words[j] + ' '; + if (len > maxlen) { + out += morelink; + maxlen = html.length * 2; + } + } + out += '\n'; + } + else + out += lines[i] + '\n'; + } + + if (maxlen > str.length) + out += ''; + + html = out; + } + + // simple link parser (similar to rcube_string_replacer class in PHP) + var utf_domain = '[^?&@"\'/\\(\\)\\s\\r\\t\\n]+\\.([^\x00-\x2f\x3b-\x40\x5b-\x60\x7b-\x7f]{2,}|xn--[a-z0-9]{2,})'; + var url1 = '.:;,', url2 = 'a-z0-9%=#@+?&/_~\\[\\]-'; + var link_pattern = new RegExp('([hf]t+ps?://)('+utf_domain+'(['+url1+']?['+url2+']+)*)?', 'ig'); + var mailto_pattern = new RegExp('([^\\s\\n\\(\\);]+@'+utf_domain+')', 'ig'); + + return html + .replace(link_pattern, '$1$2') + .replace(mailto_pattern, '$1') + .replace(/(mailto:)([^"]+)"/g, '$1$2" onclick="rcmail.command(\'compose\', \'$2\');return false"') + .replace(/\n/g, "
      "); + } + + /** + * Clear any text selection + * (text is probably selected when double-clicking somewhere) + */ + function clearSelection() + { + if (document.selection && document.selection.empty) { + document.selection.empty() ; + } + else if (window.getSelection) { + var sel = window.getSelection(); + if (sel && sel.removeAllRanges) + sel.removeAllRanges(); + } + } + + /** + * Hide all open popup menus + */ + function clear_popups(e) + { + var count = 0; + $('.popupmenu:visible').each(function(i, elem){ + var menu = $(elem); + if (!menu.data('sticky') || !target_overlaps(e.target, elem)) { + menu.hide(); + count++; + } + }); + return count; + } + + /** + * Check whether the event target is a descentand of the given element + */ + function target_overlaps(target, elem) + { + while (target.parentNode) { + if (target.parentNode == elem) + return true; + target = target.parentNode; + } + return false; + } + +} + + +/* tasklist plugin UI initialization */ +var rctasks; +window.rcmail && rcmail.addEventListener('init', function(evt) { + + rctasks = new rcube_tasklist(rcmail.env.tasklist_settings); + + // register button commands + //rcmail.register_command('addtask', function(){ tasks.add_task(); }, true); + //rcmail.register_command('print', function(){ tasks.print_list(); }, true); + + rcmail.register_command('search', function(){ rctasks.quicksearch(); }, true); + rcmail.register_command('reset-search', function(){ rctasks.reset_search(); }, true); + + rctasks.init(); +}); diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php new file mode 100644 index 00000000..2a2d8156 --- /dev/null +++ b/plugins/tasklist/tasklist.php @@ -0,0 +1,485 @@ + + * + * Copyright (C) 2012, Kolab Systems AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +class tasklist extends rcube_plugin +{ + const FILTER_MASK_TODAY = 1; + const FILTER_MASK_TOMORROW = 2; + const FILTER_MASK_WEEK = 4; + const FILTER_MASK_LATER = 8; + const FILTER_MASK_NODATE = 16; + const FILTER_MASK_OVERDUE = 32; + const FILTER_MASK_FLAGGED = 64; + const FILTER_MASK_COMPLETE = 128; + + public static $filter_masks = array( + 'today' => self::FILTER_MASK_TODAY, + 'tomorrow' => self::FILTER_MASK_TOMORROW, + 'week' => self::FILTER_MASK_WEEK, + 'later' => self::FILTER_MASK_LATER, + 'nodate' => self::FILTER_MASK_NODATE, + 'overdue' => self::FILTER_MASK_OVERDUE, + 'flagged' => self::FILTER_MASK_FLAGGED, + 'complete' => self::FILTER_MASK_COMPLETE, + ); + + public $task = '?(?!login|logout).*'; + public $rc; + public $driver; + public $timezone; + public $ui; + + public $defaults = array( + 'date_format' => "Y-m-d", + 'time_format' => "H:i", + 'first_day' => 1, + ); + + + /** + * Plugin initialization. + */ + function init() + { + $this->rc = rcmail::get_instance(); + + $this->register_task('tasks', 'tasklist'); + + // load plugin configuration + $this->load_config(); + + // load localizations + $this->add_texts('localization/', $this->rc->task == 'tasks' && (!$this->rc->action || $this->rc->action == 'print')); + + if ($this->rc->task == 'tasks' && $this->rc->action != 'save-pref') { + $this->load_driver(); + + // register calendar actions + $this->register_action('index', array($this, 'tasklist_view')); + $this->register_action('task', array($this, 'task_action')); + $this->register_action('tasklist', array($this, 'tasklist_action')); + $this->register_action('counts', array($this, 'fetch_counts')); + $this->register_action('fetch', array($this, 'fetch_tasks')); + } + + if (!$this->rc->output->ajax_call && !$this->rc->output->env['framed']) { + require_once($this->home . '/tasklist_ui.php'); + $this->ui = new tasklist_ui($this); + $this->ui->init(); + } + } + + + /** + * Helper method to load the backend driver according to local config + */ + private function load_driver() + { + if (is_object($this->driver)) + return; + + $driver_name = $this->rc->config->get('tasklist_driver', 'database'); + $driver_class = 'tasklist_' . $driver_name . '_driver'; + + require_once($this->home . '/drivers/tasklist_driver.php'); + require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); + + switch ($driver_name) { + case "kolab": + $this->require_plugin('libkolab'); + default: + $this->driver = new $driver_class($this); + break; + } + + // get user's timezone + $this->timezone = new DateTimeZone($this->rc->config->get('timezone', 'GMT')); + } + + + /** + * + */ + public function task_action() + { + $action = get_input_value('action', RCUBE_INPUT_GPC); + $rec = get_input_value('t', RCUBE_INPUT_POST, true); + $oldrec = $rec; + $success = $refresh = false; + + switch ($action) { + case 'new': + $oldrec = null; + $rec = $this->prepare_task($rec); + $rec['uid'] = $this->generate_uid(); + $temp_id = $rec['tempid']; + if ($success = $this->driver->create_task($rec)) { + $refresh = $this->driver->get_task($rec); + if ($temp_id) $refresh['tempid'] = $temp_id; + } + break; + + case 'edit': + $rec = $this->prepare_task($rec); + if ($success = $this->driver->edit_task($rec)) + $refresh = $this->driver->get_task($rec); + break; + + case 'delete': + if (!($success = $this->driver->delete_task($rec, false))) + $this->rc->output->command('plugin.reload_data'); + break; + + case 'undelete': + if ($success = $this->driver->undelete_task($rec)) + $refresh = $this->driver->get_task($rec); + break; + } + + if ($success) { + $this->rc->output->show_message('successfullysaved', 'confirmation'); + $this->update_counts($oldrec, $refresh); + } + else + $this->rc->output->show_message('tasklist.errorsaving', 'error'); + + // unlock client + $this->rc->output->command('plugin.unlock_saving'); + + if ($refresh) { + $this->encode_task($refresh); + $this->rc->output->command('plugin.refresh_task', $refresh); + } + } + + /** + * repares new/edited task properties before save + */ + private function prepare_task($rec) + { + // try to be smart and extract date from raw input + if ($rec['raw']) { + foreach (array('today','tomorrow','sunday','monday','tuesday','wednesday','thursday','friday','saturday','sun','mon','tue','wed','thu','fri','sat') as $word) { + $locwords[] = '/^' . preg_quote(mb_strtolower($this->gettext($word))) . '\b/i'; + $normwords[] = $word; + $datewords[] = $word; + } + foreach (array('jan','feb','mar','apr','may','jun','jul','aug','sep','oct','now','dec') as $month) { + $locwords[] = '/(' . preg_quote(mb_strtolower($this->gettext('long'.$month))) . '|' . preg_quote(mb_strtolower($this->gettext($month))) . ')\b/i'; + $normwords[] = $month; + $datewords[] = $month; + } + foreach (array('on','this','next','at') as $word) { + $fillwords[] = preg_quote(mb_strtolower($this->gettext($word))); + $fillwords[] = $word; + } + + $raw = trim($rec['raw']); + $date_str = ''; + + // translate localized keywords + $raw = preg_replace('/^(' . join('|', $fillwords) . ')\s*/i', '', $raw); + $raw = preg_replace($locwords, $normwords, $raw); + + // find date pattern + $date_pattern = '!^(\d+[./-]\s*)?((?:\d+[./-])|' . join('|', $datewords) . ')\.?(\s+\d{4})?[:;,]?\s+!i'; + if (preg_match($date_pattern, $raw, $m)) { + $date_str .= $m[1] . $m[2] . $m[3]; + $raw = preg_replace(array($date_pattern, '/^(' . join('|', $fillwords) . ')\s*/i'), '', $raw); + // add year to date string + if ($m[1] && !$m[3]) + $date_str .= date('Y'); + } + + // find time pattern + $time_pattern = '/^(\d+([:.]\d+)?(\s*[hapm.]+)?),?\s+/i'; + if (preg_match($time_pattern, $raw, $m)) { + $has_time = true; + $date_str .= ($date_str ? ' ' : 'today ') . $m[1]; + $raw = preg_replace($time_pattern, '', $raw); + } + + // yes, raw input matched a (valid) date + if (strlen($date_str) && strtotime($date_str) && ($date = new DateTime($date_str, $this->timezone))) { + $rec['date'] = $date->format('Y-m-d'); + if ($has_time) + $rec['time'] = $date->format('H:i'); + $rec['title'] = $raw; + } + else + $rec['title'] = $rec['raw']; + } + + // normalize input from client + if (isset($rec['complete'])) { + $rec['complete'] = floatval($rec['complete']); + if ($rec['complete'] > 1) + $rec['complete'] /= 100; + } + if (isset($rec['flagged'])) + $rec['flagged'] = intval($rec['flagged']); + + // fix for garbage input + if ($rec['description'] == 'null') + $rec['description'] = ''; + + foreach ($rec as $key => $val) { + if ($val == 'null') + $rec[$key] = null; + } + + if (!empty($rec['date'])) { + try { + $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); + $rec['date'] = $date->format('Y-m-d'); + if (!empty($rec['time'])) + $rec['time'] = $date->format('H:i'); + } + catch (Exception $e) { + $rec['date'] = $rec['time'] = null; + } + } + + return $rec; + } + + /** + * + */ + public function tasklist_action() + { + $action = get_input_value('action', RCUBE_INPUT_GPC); + $list = get_input_value('l', RCUBE_INPUT_POST, true); + $success = false; + + switch ($action) { + + } + + if ($success) + $this->rc->output->show_message('successfullysaved', 'confirmation'); + else + $this->rc->output->show_message('tasklist.errorsaving', 'error'); + } + + /** + * + */ + public function fetch_counts() + { + $lists = null; + $counts = $this->driver->count_tasks($lists); + $this->rc->output->command('plugin.update_counts', $counts); + } + + /** + * Adjust the cached counts after changing a task + * + * + */ + public function update_counts($oldrec, $newrec) + { + // rebuild counts until this function is finally implemented + $this->fetch_counts(); + + // $this->rc->output->command('plugin.update_counts', $counts); + } + + /** + * + */ + public function fetch_tasks() + { + $f = intval(get_input_value('filter', RCUBE_INPUT_GPC)); + $search = get_input_value('q', RCUBE_INPUT_GPC); + $filter = array('mask' => $f, 'search' => $search); + $lists = null; + + // convert magic date filters into a real date range + switch ($f) { + case self::FILTER_MASK_TODAY: + $today = new DateTime('now', $this->timezone); + $filter['from'] = $filter['to'] = $today->format('Y-m-d'); + break; + + case self::FILTER_MASK_TOMORROW: + $tomorrow = new DateTime('now + 1 day', $this->timezone); + $filter['from'] = $filter['to'] = $tomorrow->format('Y-m-d'); + break; + + case self::FILTER_MASK_OVERDUE: + $yesterday = new DateTime('yesterday', $this->timezone); + $filter['to'] = $yesterday->format('Y-m-d'); + break; + + case self::FILTER_MASK_WEEK: + $today = new DateTime('now', $this->timezone); + $filter['from'] = $today->format('Y-m-d'); + $weekend = new DateTime('now + 7 days', $this->timezone); + $filter['to'] = $weekend->format('Y-m-d'); + break; + + case self::FILTER_MASK_LATER: + $date = new DateTime('now + 8 days', $this->timezone); + $filter['from'] = $date->format('Y-m-d'); + break; + + } + + $data = $this->task_tree = $this->tasks_childs = array(); + foreach ($this->driver->list_tasks($filter, $lists) as $rec) { + if ($rec['parent_id']) { + $this->tasks_childs[$rec['parent_id']]++; + $this->task_tree[$rec['id']] = $rec['parent_id']; + } + $this->encode_task($rec); + + // apply filter; don't trust the driver on this :-) + if ((!$f && $rec['complete'] < 1.0) || ($rec['mask'] & $f)) + $data[] = $rec; + } + + // sort tasks according to their hierarchy level and due date + usort($data, array($this, 'task_sort_cmp')); + + $this->rc->output->command('plugin.data_ready', $data); + } + + /** + * Prepare the given task record before sending it to the client + */ + private function encode_task(&$rec) + { + $rec['mask'] = $this->filter_mask($rec); + $rec['flagged'] = intval($rec['flagged']); + $rec['complete'] = floatval($rec['complete']); + + if ($rec['date']) { + try { + $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); + $rec['datetime'] = intval($date->format('U')); + $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); + $rec['_hasdate'] = 1; + } + catch (Exception $e) { + $rec['date'] = $rec['datetime'] = null; + } + } + else { + $rec['date'] = $rec['datetime'] = null; + $rec['_hasdate'] = 0; + } + + if ($this->tasks_childs[$rec['id']]) + $rec['_haschilds'] = $this->tasks_childs[$rec['id']]; + + if (!isset($rec['_depth'])) { + $rec['_depth'] = 0; + $parent_id = $this->task_tree[$rec['id']]; + while ($parent_id) { + $rec['_depth']++; + $parent_id = $this->task_tree[$parent_id]; + } + } + } + + /** + * Compare function for task list sorting. + * Nested tasks need to be sorted to the end. + */ + private function task_sort_cmp($a, $b) + { + $d = $a['_depth'] - $b['_depth']; + if (!$d) $d = $b['_hasdate'] - $a['_hasdate']; + if (!$d) $d = $a['datetime'] - $b['datetime']; + return $d; + } + + /** + * Compute the filter mask of the given task + * + * @param array Hash array with Task record properties + * @return int Filter mask + */ + public function filter_mask($rec) + { + static $today, $tomorrow, $weeklimit; + + if (!$today) { + $today_date = new DateTime('now', $this->timezone); + $today = $today_date->format('Y-m-d'); + $tomorrow_date = new DateTime('now + 1 day', $this->timezone); + $tomorrow = $tomorrow_date->format('Y-m-d'); + $week_date = new DateTime('now + 7 days', $this->timezone); + $weeklimit = $week_date->format('Y-m-d'); + } + + $mask = 0; + if ($rec['flagged']) + $mask |= self::FILTER_MASK_FLAGGED; + if ($rec['complete'] == 1.0) + $mask |= self::FILTER_MASK_COMPLETE; + if (empty($rec['date'])) + $mask |= self::FILTER_MASK_NODATE; + else if ($rec['date'] == $today) + $mask |= self::FILTER_MASK_TODAY; + else if ($rec['date'] == $tomorrow) + $mask |= self::FILTER_MASK_TOMORROW; + else if ($rec['date'] < $today) + $mask |= self::FILTER_MASK_OVERDUE; + else if ($rec['date'] > $tomorrow && $rec['date'] <= $weeklimit) + $mask |= self::FILTER_MASK_LATER; + else if ($rec['date'] > $weeklimit) + $mask |= self::FILTER_MASK_LATER; + + return $mask; + } + + + /******* UI functions ********/ + + /** + * Render main view of the tasklist task + */ + public function tasklist_view() + { + $this->ui->init(); + $this->ui->init_templates(); + $this->rc->output->set_pagetitle($this->gettext('navtitle')); + $this->rc->output->send('tasklist.mainview'); + } + + + /******* Utility functions *******/ + + /** + * Generate a unique identifier for an event + */ + public function generate_uid() + { + return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); + } + +} + diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php new file mode 100644 index 00000000..50ae64ae --- /dev/null +++ b/plugins/tasklist/tasklist_ui.php @@ -0,0 +1,181 @@ + + * + * 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_ui +{ + private $rc; + private $plugin; + private $ready = false; + + function __construct($plugin) + { + $this->plugin = $plugin; + $this->rc = $plugin->rc; + } + + /** + * Calendar UI initialization and requests handlers + */ + public function init() + { + if ($this->ready) // already done + return; + + // add taskbar button + $this->plugin->add_button(array( + 'command' => 'tasks', + 'class' => 'button-tasklist', + 'classsel' => 'button-tasklist button-selected', + 'innerclass' => 'button-inner', + 'label' => 'tasklist.navtitle', + ), 'taskbar'); + + $skin = $this->rc->config->get('skin'); + $this->plugin->include_stylesheet('skins/' . $skin . '/tasklist.css'); + $this->ready = true; + } + + /** + * Register handler methods for the template engine + */ + public function init_templates() + { + $this->plugin->register_handler('plugin.tasklists', array($this, 'tasklists')); + $this->plugin->register_handler('plugin.tasklist_select', array($this, 'tasklist_select')); + $this->plugin->register_handler('plugin.category_select', array($this, 'category_select')); + $this->plugin->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); + $this->plugin->register_handler('plugin.quickaddform', array($this, 'quickadd_form')); + $this->plugin->register_handler('plugin.tasks', array($this, 'tasks_resultview')); + + $this->plugin->include_script('tasklist.js'); + + // copy config to client + $defaults = $this->plugin->defaults; + $settings = array( + 'date_format' => $this->rc->config->get('date_format', $defaults['date_format']), + 'time_format' => $this->rc->config->get('time_format', $defaults['time_format']), + 'first_day' => $this->rc->config->get('calendar_first_day', $defaults['first_day']), + ); + + $this->rc->output->set_env('tasklist_settings', $settings); + } + + /** + * + */ + function tasklists($attrib = array()) + { + $lists = $this->plugin->driver->get_lists(); + + $li = ''; + foreach ((array)$lists as $id => $prop) { + if ($attrib['activeonly'] && !$prop['active']) + continue; + + unset($prop['user_id']); + $prop['alarms'] = $this->plugin->driver->alarms; + $prop['undelete'] = $this->plugin->driver->undelete; + $prop['sortable'] = $this->plugin->driver->sortable; + $jsenv[$id] = $prop; + + $html_id = html_identifier($id); + $class = 'tasks-' . asciiwords($id, true); + + if ($prop['readonly']) + $class .= ' readonly'; + if ($prop['class_name']) + $class .= ' '.$prop['class_name']; + + $li .= html::tag('li', array('id' => 'rcmlitasklist' . $html_id, 'class' => $class), + html::tag('input', array('type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active'], 'disabled' => true)) . + html::span('handle', ' ') . + html::span('listname', Q($prop['name']))); + } + + $this->rc->output->set_env('tasklists', $jsenv); + $this->rc->output->add_gui_object('folderlist', $attrib['id']); + + return html::tag('ul', $attrib, $li, html::$common_attrib); + } + + + /** + * Render a HTML select box for list selection + */ + function tasklist_select($attrib = array()) + { + $attrib['name'] = 'list'; + $select = new html_select($attrib); + foreach ((array)$this->plugin->driver->get_lists() as $id => $prop) { + if (!$prop['readonly']) + $select->add($prop['name'], $id); + } + + return $select->show(null); + } + + /** + * Render a HTML select box to select a task category + */ + function category_select($attrib = array()) + { + $attrib['name'] = 'categories'; + $select = new html_select($attrib); + $select->add('---', ''); + foreach ((array)$this->plugin->driver->list_categories() as $cat => $color) { + $select->add($cat, $cat); + } + + return $select->show(null); + } + + /** + * + */ + function quickadd_form($attrib) + { + $attrib += array('action' => $this->rc->url('add'), 'method' => 'post', 'id' => 'quickaddform'); + + $input = new html_inputfield(array('name' => 'text', 'id' => 'quickaddinput', 'placeholder' => $this->plugin->gettext('createnewtask'))); + $button = html::tag('input', array('type' => 'submit', 'value' => '+', 'class' => 'button mainaction')); + + $this->rc->output->add_gui_object('quickaddform', $attrib['id']); + return html::tag('form', $attrib, $input->show() . $button); + } + + /** + * The result view + */ + function tasks_resultview($attrib) + { + $attrib += array('id' => 'rcmtaskslist'); + + $this->rc->output->add_gui_object('resultlist', $attrib['id']); + + unset($attrib['name']); + return html::tag('ul', $attrib, ''); + } + + +}