diff --git a/plugins/tasklist/drivers/database/tasklist_database_driver.php b/plugins/tasklist/drivers/database/tasklist_database_driver.php index 90c04dbc..58e8d32f 100644 --- a/plugins/tasklist/drivers/database/tasklist_database_driver.php +++ b/plugins/tasklist/drivers/database/tasklist_database_driver.php @@ -1,855 +1,856 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class 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 $tags = 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; $arr['rights'] = 'lrswikxtea'; $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($filter = 0) { // 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) { + $prop['rights'] = 'lrswikxtea'; 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` = ?", strval($prop['name']), strval($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::delete_list() */ public function delete_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; } /** * Search for shared or otherwise not listed tasklists the user has access * * @param string Search string * @param string Section/source to search * @return array List of tasklists */ public function search_lists($query, $source) { return array(); } /** * Get a list of tags to assign tasks to * * @return array List of tags */ public function get_tags() { return array_values(array_unique($this->tags, SORT_STRING)); } /** * 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_array($lists)) { $lists = explode(',', (string) $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 NOT " . self::IS_COMPLETE_SQL, join(',', $list_ids) )); $counts = array('all' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'later' => 0); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $counts['all']++; if (empty($rec['date'])) $counts['later']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; else if ($rec['date'] > $tomorrow) $counts['later']++; } return $counts; } /** * Get all task records matching the given filter * * @param array Hash array 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_array($lists)) { $lists = explode(',', (string) $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 " . self::IS_COMPLETE_SQL; } else if (empty($filter['since'])) { // don't show complete tasks by default $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'])); } if ($filter['uid']) { $sql_add .= " AND `uid` IN (" . implode(',', array_map(array($this->rc->db, 'quote'), $filter['uid'])) . ")"; } $tasks = array(); if (!empty($list_ids)) { $result = $this->rc->db->query("SELECT * FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . join(',', $list_ids) . ")" . " AND `del` = 0" . $sql_add . " ORDER BY `parent_id`, `task_id` ASC", $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 * @param integer Bitmask defining filter criterias. * See FILTER_* constants for possible values. * * @return array Hash array with task properties or false if not found */ public function get_task($prop, $filter = 0) { if (is_string($prop)) { $prop['uid'] = $prop; } $query_col = $prop['id'] ? 'task_id' : 'uid'; $result = $this->rc->db->query("SELECT * FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . $this->list_ids . ")" . " AND `$query_col` = ? AND `del` = 0", $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( "SELECT `task_id` AS id, `tasklist_id` AS list FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . $this->list_ids . ")" . " AND `uid` = ?", $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( "SELECT `task_id` AS id FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . $this->list_ids . ")" . " AND `parent_id` IN (" . join(',', array_map(array($this->rc->db, 'quote'), $task_ids)) . ")" . " AND `del` = 0", ); $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_array($lists)) { $lists = explode(',', (string) $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("SELECT * FROM " . $this->db_tasks . " WHERE `tasklist_id` IN (" . join(',', $list_ids) . ")" . " AND `notify` <= " . $this->rc->db->fromunixtime($time) . " AND NOT " . self::IS_COMPLETE_SQL, ); 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("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ", `notify` = ?" . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $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['created'] = new DateTime($rec['created']); $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']); } if (!empty($rec['tags'])) { $this->tags = array_merge($this->tags, (array)$rec['tags']); } unset($rec['task_id'], $rec['tasklist_id']); 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']); } if (array_key_exists('complete', $prop)) { $prop['complete'] = number_format($prop['complete'], 2, '.', ''); } 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); $now = $this->rc->db->now(); $result = $this->rc->db->query("INSERT INTO " . $this->db_tasks . " (`tasklist_id`, `uid`, `parent_id`, `created`, `changed`, `title`, `date`, `time`," . " `startdate`, `starttime`, `description`, `tags`, `flagged`, `complete`, `status`," . " `alarms`, `recurrence`, `notify`)" . " VALUES (?, ?, ?, $now, $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']), strval($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']); } if (array_key_exists('complete', $prop)) { $prop['complete'] = number_format($prop['complete'], 2, '.', ''); } $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) { 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['status'])) { $sql_set[] = $this->rc->db->quote_identifier('status') . '=' . $this->rc->db->quote($prop['status']); } 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[] = $this->rc->db->quote_identifier('tasklist_id') . '=' . $this->rc->db->quote($newlist); } $result = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ($sql_set ? ', ' . join(', ', $sql_set) : '') . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $prop['id'] ); return $this->rc->db->affected_rows($result); } /** * 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) { $result = $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) { $result = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ", `del` = 1" . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $task_id ); } return $this->rc->db->affected_rows($result); } /** * 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) { $result = $this->rc->db->query("UPDATE " . $this->db_tasks . " SET `changed` = " . $this->rc->db->now() . ", `del` = 0" . " WHERE `task_id` = ? AND `tasklist_id` IN (" . $this->list_ids . ")", $prop['id'] ); return $this->rc->db->affected_rows($result); } /** * Compute absolute time to notify the user */ private function _get_notification($task) { 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']); } } } /** * 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_alarm_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; $lists = $db->query("SELECT `tasklist_id` FROM " . $this->db_lists . " WHERE `user_id` = ?", $args['user']->ID); $list_ids = array(); 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))); } } } }