* * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist_kolab_driver extends tasklist_driver { // features supported by the backend public $alarms = false; public $attachments = true; public $undelete = false; // task undelete action public $alarm_types = array('DISPLAY'); private $rc; private $plugin; private $lists; private $folders = array(); private $tasks = array(); /** * Default constructor */ public function __construct($plugin) { $this->rc = $plugin->rc; $this->plugin = $plugin; $this->_read_lists(); if (kolab_storage::$version == '2.0') { $this->alarm_absolute = false; } $this->plugin->register_action('folder-acl', array($this, 'folder_acl')); } /** * Read available calendars for the current user and store them internally */ private function _read_lists($force = false) { // already read sources if (isset($this->lists) && !$force) return $this->lists; // get all folders that have type "task" $folders = kolab_storage::sort_folders(kolab_storage::get_folders('task')); $this->lists = $this->folders = array(); // find default folder $default_index = 0; foreach ($folders as $i => $folder) { if ($folder->default) $default_index = $i; } // put default folder (aka INBOX) on top of the list if ($default_index > 0) { $default_folder = $folders[$default_index]; unset($folders[$default_index]); array_unshift($folders, $default_folder); } $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); $prefs = $this->rc->config->get('kolab_tasklists', array()); $listnames = array(); // include virtual folders for a full folder tree if (!$this->rc->output->ajax_call && in_array($this->rc->action, array('index',''))) $folders = kolab_storage::folder_hierarchy($folders); foreach ($folders as $folder) { $utf7name = $folder->name; $path_imap = explode($delim, $utf7name); $editname = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP'); // pop off raw name part $path_imap = join($delim, $path_imap); $fullname = $folder->get_name(); $listname = kolab_storage::folder_displayname($fullname, $listnames); // special handling for virtual folders if ($folder->virtual) { $list_id = kolab_storage::folder_id($utf7name); $this->lists[$list_id] = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'virtual' => true, 'editable' => false, ); continue; } if ($folder->get_namespace() == 'personal') { $norename = false; $readonly = false; $alarms = true; } else { $alarms = false; $readonly = true; if (($rights = $folder->get_myrights()) && !PEAR::isError($rights)) { if (strpos($rights, 'i') !== false) $readonly = false; } $info = $folder->get_folder_info(); $norename = $readonly || $info['norename'] || $info['protected']; } $list_id = kolab_storage::folder_id($utf7name); $tasklist = array( 'id' => $list_id, 'name' => $fullname, 'listname' => $listname, 'editname' => $editname, 'color' => $folder->get_color('0000CC'), 'showalarms' => isset($prefs[$list_id]['showalarms']) ? $prefs[$list_id]['showalarms'] : $alarms, 'editable' => !$readionly, 'norename' => $norename, 'active' => $folder->is_active(), 'parentfolder' => $path_imap, 'default' => $folder->default, 'children' => true, // TODO: determine if that folder indeed has child folders 'class_name' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), ); $this->lists[$tasklist['id']] = $tasklist; $this->folders[$tasklist['id']] = $folder; $this->folders[$folder->name] = $folder; } } /** * Get a list of available task lists from this source */ public function get_lists() { // attempt to create a default list for this user if (empty($this->lists)) { if ($this->create_list(array('name' => 'Tasks', 'color' => '0000CC', 'default' => true))) $this->_read_lists(true); } return $this->lists; } /** * Create a new list assigned to the current user * * @param array Hash array with list properties * name: List name * color: The color of the list * showalarms: True if alarms are enabled * @return mixed ID of the new list on success, False on error */ public function create_list(&$prop) { $prop['type'] = 'task' . ($prop['default'] ? '.default' : ''); $prop['active'] = true; // activate folder by default $prop['subscribed'] = true; $folder = kolab_storage::folder_update($prop); if ($folder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($folder); $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload to properly render folder hierarchy if (!empty($prop['parent'])) $prop['_reload'] = true; return $id; } /** * Update properties of an existing tasklist * * @param array Hash array with list properties * id: List Identifier * name: List name * color: The color of the list * showalarms: True if alarms are enabled (if supported) * @return boolean True on success, Fales on failure */ public function edit_list(&$prop) { if ($prop['id'] && ($folder = $this->folders[$prop['id']])) { $prop['oldname'] = $folder->name; $prop['type'] = 'task'; $newfolder = kolab_storage::folder_update($prop); if ($newfolder === false) { $this->last_error = kolab_storage::$last_error; return false; } // create ID $id = kolab_storage::folder_id($newfolder); // fallback to local prefs $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', array()); unset($prefs['kolab_tasklists'][$prop['id']]); if (isset($prop['showalarms'])) $prefs['kolab_tasklists'][$id]['showalarms'] = $prop['showalarms'] ? true : false; if ($prefs['kolab_tasklists'][$id]) $this->rc->user->save_prefs($prefs); // force page reload if folder name/hierarchy changed if ($newfolder != $prop['oldname']) $prop['_reload'] = true; return $id; } return false; } /** * Set active/subscribed state of a list * * @param array Hash array with list properties * id: List Identifier * active: True if list is active, false if not * @return boolean True on success, Fales on failure */ public function subscribe_list($prop) { if ($prop['id'] && ($folder = $this->folders[$prop['id']])) { return $folder->activate($prop['active']); } return false; } /** * Delete the given list with all its contents * * @param array Hash array with list properties * id: list Identifier * @return boolean True on success, Fales on failure */ public function remove_list($prop) { if ($prop['id'] && ($folder = $this->folders[$prop['id']])) { if (kolab_storage::folder_delete($folder->name)) return true; else $this->last_error = kolab_storage::$last_error; } return false; } /** * Get number of tasks matching the given filter * * @param array List of lists to count tasks of * @return array Hash array with counts grouped by status (all|flagged|completed|today|tomorrow|nodate) */ public function count_tasks($lists = null) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); $today_date = new DateTime('now', $this->plugin->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new DateTime('now + 1 day', $this->plugin->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); $counts = array('all' => 0, 'flagged' => 0, 'today' => 0, 'tomorrow' => 0, 'overdue' => 0, 'nodate' => 0); foreach ($lists as $list_id) { $folder = $this->folders[$list_id]; foreach ($folder->select(array(array('tags','!~','x-complete'))) as $record) { $rec = $this->_to_rcube_task($record); if ($rec['complete'] >= 1.0) // don't count complete tasks continue; $counts['all']++; if ($rec['flagged']) $counts['flagged']++; if (empty($rec['date'])) $counts['nodate']++; else if ($rec['date'] == $today) $counts['today']++; else if ($rec['date'] == $tomorrow) $counts['tomorrow']++; else if ($rec['date'] < $today) $counts['overdue']++; } } return $counts; } /** * Get all taks records matching the given filter * * @param array Hash array with filter criterias: * - mask: Bitmask representing the filter selection (check against tasklist::FILTER_MASK_* constants) * - from: Date range start as string (Y-m-d) * - to: Date range end as string (Y-m-d) * - search: Search query string * @param array List of lists to get tasks from * @return array List of tasks records matchin the criteria */ public function list_tasks($filter, $lists = null) { if (empty($lists)) $lists = array_keys($this->lists); else if (is_string($lists)) $lists = explode(',', $lists); $results = array(); // query Kolab storage $query = array(); if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $query[] = array('tags','~','x-complete'); else if (empty($filter['since'])) $query[] = array('tags','!~','x-complete'); // full text search (only works with cache enabled) if ($filter['search']) { $search = mb_strtolower($filter['search']); foreach (rcube_utils::normalize_string($search, true) as $word) { $query[] = array('words', '~', $word); } } if ($filter['since']) { $query[] = array('changed', '>=', $filter['since']); } foreach ($lists as $list_id) { $folder = $this->folders[$list_id]; foreach ($folder->select($query) as $record) { $task = $this->_to_rcube_task($record); $task['list'] = $list_id; // TODO: post-filter tasks returned from storage $results[] = $task; } } return $results; } /** * Return data of a specific task * * @param mixed Hash array with task properties or task UID * @return array Hash array with task properties or false if not found */ public function get_task($prop) { $id = is_array($prop) ? ($prop['uid'] ?: $prop['id']) : $prop; $list_id = is_array($prop) ? $prop['list'] : null; $folders = $list_id ? array($list_id => $this->folders[$list_id]) : $this->folders; // find task in the available folders foreach ($folders as $list_id => $folder) { if (is_numeric($list_id)) continue; if (!$this->tasks[$id] && ($object = $folder->get_object($id))) { $this->tasks[$id] = $this->_to_rcube_task($object); $this->tasks[$id]['list'] = $list_id; break; } } return $this->tasks[$id]; } /** * Get all decendents of the given task record * * @param mixed Hash array with task properties or task UID * @param boolean True if all childrens children should be fetched * @return array List of all child task IDs */ public function get_childs($prop, $recursive = false) { if (is_string($prop)) { $task = $this->get_task($prop); $prop = array('id' => $task['id'], 'list' => $task['list']); } $childs = array(); $list_id = $prop['list']; $task_ids = array($prop['id']); $folder = $this->folders[$list_id]; // query for childs (recursively) while ($folder && !empty($task_ids)) { $query_ids = array(); foreach ($task_ids as $task_id) { $query = array(array('tags','=','x-parent:' . $task_id)); foreach ($folder->select($query) as $record) { // don't rely on kolab_storage_folder filtering if ($record['parent_id'] == $task_id) { $childs[] = $record['uid']; $query_ids[] = $record['uid']; } } } if (!$recursive) break; $task_ids = $query_ids; } return $childs; } /** * Get a list of pending alarms to be displayed to the user * * @param integer Current time (unix timestamp) * @param mixed List of list IDs to show alarms for (either as array or comma-separated string) * @return array A list of alarms, each encoded as hash array with task properties * @see tasklist_driver::pending_alarms() */ public function pending_alarms($time, $lists = null) { $interval = 300; $time -= $time % 60; $slot = $time; $slot -= $slot % $interval; $last = $time - max(60, $this->rc->config->get('refresh_interval', 0)); $last -= $last % $interval; // only check for alerts once in 5 minutes if ($last == $slot) return array(); if ($lists && is_string($lists)) $lists = explode(',', $lists); $time = $slot + $interval; $tasks = array(); $query = array(array('tags', '=', 'x-has-alarms'), array('tags', '!=', 'x-complete')); foreach ($this->lists as $lid => $list) { // skip lists with alarms disabled if (!$list['showalarms'] || ($lists && !in_array($lid, $lists))) continue; $folder = $this->folders[$lid]; foreach ($folder->select($query) as $record) { if (!$record['alarms'] || $record['status'] == 'COMPLETED' || $record['complete'] == 100) // don't trust query :-) continue; $task = $this->_to_rcube_task($record); // add to list if alarm is set $alarm = libcalendaring::get_next_alarm($task, 'task'); if ($alarm && $alarm['time'] && $alarm['time'] <= $time && $alarm['action'] == 'DISPLAY') { $id = $task['id']; $tasks[$id] = $task; $tasks[$id]['notifyat'] = $alarm['time']; } } } // get alarm information stored in local database if (!empty($tasks)) { $task_ids = array_map(array($this->rc->db, 'quote'), array_keys($tasks)); $result = $this->rc->db->query(sprintf( "SELECT * FROM kolab_alarms WHERE event_id IN (%s) AND user_id=?", join(',', $task_ids), $this->rc->db->now() ), $this->rc->user->ID ); while ($result && ($rec = $this->rc->db->fetch_assoc($result))) { $dbdata[$rec['event_id']] = $rec; } } $alarms = array(); foreach ($tasks as $id => $task) { // skip dismissed if ($dbdata[$id]['dismissed']) continue; // snooze function may have shifted alarm time $notifyat = $dbdata[$id]['notifyat'] ? strtotime($dbdata[$id]['notifyat']) : $task['notifyat']; if ($notifyat <= $time) $alarms[] = $task; } return $alarms; } /** * (User) feedback after showing an alarm notification * This should mark the alarm as 'shown' or snooze it for the given amount of time * * @param string Task identifier * @param integer Suspend the alarm for this number of seconds */ public function dismiss_alarm($id, $snooze = 0) { // delete old alarm entry $this->rc->db->query( "DELETE FROM kolab_alarms WHERE event_id=? AND user_id=?", $id, $this->rc->user->ID ); // set new notifyat time or unset if not snoozed $notifyat = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null; $query = $this->rc->db->query( "INSERT INTO kolab_alarms (event_id, user_id, dismissed, notifyat) VALUES(?, ?, ?, ?)", $id, $this->rc->user->ID, $snooze > 0 ? 0 : 1, $notifyat ); return $this->rc->db->affected_rows($query); } /** * Convert from Kolab_Format to internal representation */ private function _to_rcube_task($record) { $task = array( 'id' => $record['uid'], 'uid' => $record['uid'], 'title' => $record['title'], # 'location' => $record['location'], 'description' => $record['description'], 'tags' => array_filter((array)$record['categories']), 'flagged' => $record['priority'] == 1, 'complete' => $record['status'] == 'COMPLETED' ? 1 : floatval($record['complete'] / 100), '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'); if (!$record['due']->_dateonly) $task['time'] = $record['due']->format('H:i'); } // convert from DateTime to internal date format if (is_a($record['start'], 'DateTime')) { $task['startdate'] = $record['start']->format('Y-m-d'); if (!$record['start']->_dateonly) $task['starttime'] = $record['start']->format('H:i'); } if (is_a($record['dtstamp'], 'DateTime')) { $task['changed'] = $record['dtstamp']; } if ($record['alarms']) { $task['alarms'] = $record['alarms']; } if (!empty($record['_attachments'])) { foreach ($record['_attachments'] as $key => $attachment) { if ($attachment !== false) { if (!$attachment['name']) $attachment['name'] = $key; $attachments[] = $attachment; } } $task['attachments'] = $attachments; } return $task; } /** * Convert the given task record into a data structure that can be passed to kolab_storage backend for saving * (opposite of self::_to_rcube_event()) */ private function _from_rcube_task($task, $old = array()) { $object = $task; $object['categories'] = (array)$task['tags']; if (!empty($task['date'])) { $object['due'] = new DateTime($task['date'].' '.$task['time'], $this->plugin->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = new DateTime($task['startdate'].' '.$task['starttime'], $this->plugin->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0) $object['status'] = 'COMPLETED'; if ($task['flagged']) $object['priority'] = 1; else $object['priority'] = $old['priority'] > 1 ? $old['priority'] : 0; // copy meta data (starting with _) from old object foreach ((array)$old as $key => $val) { if (!isset($object[$key]) && $key[0] == '_') $object[$key] = $val; } // copy recurrence rules as long as the web client doesn't support it. // that way it doesn't get removed when saving through the web client (#2713) if ($old['recurrence']) { $object['recurrence'] = $old['recurrence']; } // delete existing attachment(s) if (!empty($task['deleted_attachments'])) { foreach ($task['deleted_attachments'] as $attachment) { if (is_array($object['_attachments'])) { foreach ($object['_attachments'] as $idx => $att) { if ($att['id'] == $attachment) $object['_attachments'][$idx] = false; } } } unset($task['deleted_attachments']); } // in kolab_storage attachments are indexed by content-id if (is_array($task['attachments'])) { foreach ($task['attachments'] as $idx => $attachment) { $key = null; // Roundcube ID has nothing to do with the storage ID, remove it if ($attachment['content']) { unset($attachment['id']); } else { foreach ((array)$old['_attachments'] as $cid => $oldatt) { if ($oldatt && $attachment['id'] == $oldatt['id']) $key = $cid; } } // replace existing entry if ($key) { $object['_attachments'][$key] = $attachment; } // append as new attachment else { $object['_attachments'][] = $attachment; } } unset($object['attachments']); } unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags']); return $object; } /** * Add a single task to the database * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return mixed New task ID on success, False on error */ public function create_task($task) { return $this->edit_task($task); } /** * Update an task entry with the given data * * @param array Hash array with task properties (see header of tasklist_driver.php) * @return boolean True on success, False on error */ public function edit_task($task) { $list_id = $task['list']; if (!$list_id || !($folder = $this->folders[$list_id])) return false; // moved from another folder if ($task['_fromlist'] && ($fromfolder = $this->folders[$task['_fromlist']])) { if (!$fromfolder->move($task['id'], $folder->name)) return false; unset($task['_fromlist']); } // load previous version of this task to merge if ($task['id']) { $old = $folder->get_object($task['id']); if (!$old || PEAR::isError($old)) return false; // merge existing properties if the update isn't complete if (!isset($task['title']) || !isset($task['complete'])) $task += $this->_to_rcube_task($old); } // generate new task object from RC input $object = $this->_from_rcube_task($task, $old); $saved = $folder->save($object, 'task', $task['id']); if (!$saved) { raise_error(array( 'code' => 600, 'type' => 'php', 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving task object to Kolab server"), true, false); $saved = false; } else { $task = $this->_to_rcube_task($object); $task['list'] = $list_id; $this->tasks[$task['id']] = $task; } return $saved; } /** * Move a single task to another list * * @param array Hash array with task properties: * @return boolean True on success, False on error * @see tasklist_driver::move_task() */ public function move_task($task) { $list_id = $task['list']; if (!$list_id || !($folder = $this->folders[$list_id])) return false; // execute move command if ($task['_fromlist'] && ($fromfolder = $this->folders[$task['_fromlist']])) { return $fromfolder->move($task['id'], $folder->name); } return false; } /** * Remove a single task from the database * * @param array Hash array with task properties: * id: Task identifier * @param boolean Remove record irreversible (mark as deleted otherwise, if supported by the backend) * @return boolean True on success, False on error */ public function delete_task($task, $force = true) { $list_id = $task['list']; if (!$list_id || !($folder = $this->folders[$list_id])) return false; return $folder->delete($task['id']); } /** * Restores a single deleted task (if supported) * * @param array Hash array with task properties: * id: Task identifier * @return boolean True on success, False on error */ public function undelete_task($prop) { // TODO: implement this return false; } /** * Get attachment properties * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * * @return array Hash array with attachment properties: * id: Attachment identifier * name: Attachment name * mimetype: MIME content type of the attachment * size: Attachment size */ public function get_attachment($id, $task) { $task['uid'] = $task['id']; $task = $this->get_task($task); if ($task && !empty($task['attachments'])) { foreach ($task['attachments'] as $att) { if ($att['id'] == $id) return $att; } } return null; } /** * Get attachment body * * @param string $id Attachment identifier * @param array $task Hash array with event properties: * id: Task identifier * list: List identifier * * @return string Attachment body */ public function get_attachment_body($id, $task) { if ($storage = $this->folders[$task['list']]) { return $storage->get_attachment($task['id'], $id); } return false; } /** * */ public function tasklist_edit_form($action, $list, $fieldprop) { if ($list['id'] && ($list = $this->lists[$list['id']])) { $folder_name = $this->folders[$list['id']]->name; // UTF7 } else { $folder_name = ''; } $storage = $this->rc->get_storage(); $delim = $storage->get_hierarchy_delimiter(); $form = array(); if (strlen($folder_name)) { $path_imap = explode($delim, $folder_name); array_pop($path_imap); // pop off name part $path_imap = implode($path_imap, $delim); $options = $storage->folder_info($folder_name); } else { $path_imap = ''; } $hidden_fields[] = array('name' => 'oldname', 'value' => $folder_name); // folder name (default field) $input_name = new html_inputfield(array('name' => 'name', 'id' => 'taskedit-tasklistame', 'size' => 20)); $fieldprop['name']['value'] = $input_name->show($list['editname'], array('disabled' => ($options['norename'] || $options['protected']))); // prevent user from moving folder if (!empty($options) && ($options['norename'] || $options['protected'])) { $hidden_fields[] = array('name' => 'parent', 'value' => $path_imap); } else { $select = kolab_storage::folder_selector('task', array('name' => 'parent', 'id' => 'taskedit-parentfolder'), $folder_name); $fieldprop['parent'] = array( 'id' => 'taskedit-parentfolder', 'label' => $this->plugin->gettext('parentfolder'), 'value' => $select->show($path_imap), ); } // General tab $form['properties'] = array( 'name' => $this->rc->gettext('properties'), 'fields' => array(), ); foreach (array('name','parent','showalarms') as $f) { $form['properties']['fields'][$f] = $fieldprop[$f]; } // add folder ACL tab if ($action != 'form-new') { $form['sharing'] = array( 'name' => Q($this->plugin->gettext('tabsharing')), 'content' => html::tag('iframe', array( 'src' => $this->rc->url(array('_action' => 'folder-acl', '_folder' => $folder_name, 'framed' => 1)), 'width' => '100%', 'height' => 280, 'border' => 0, 'style' => 'border:0'), '') ); } $form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $form_html .= $hiddenfield->show() . "\n"; } } // create form output foreach ($form as $tab) { if (is_array($tab['fields']) && empty($tab['content'])) { $table = new html_table(array('cols' => 2)); foreach ($tab['fields'] as $col => $colprop) { $colprop['id'] = '_'.$col; $label = !empty($colprop['label']) ? $colprop['label'] : $this->plugin->gettext($col); $table->add('title', html::label($colprop['id'], Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $tab['content']; } if (!empty($content)) { $form_html .= html::tag('fieldset', null, html::tag('legend', null, Q($tab['name'])) . $content) . "\n"; } } return $form_html; } /** * Handler to render ACL form for a notes folder */ public function folder_acl() { $this->plugin->require_plugin('acl'); $this->rc->output->add_handler('folderacl', array($this, 'folder_acl_form')); $this->rc->output->send('tasklist.kolabacl'); } /** * Handler for ACL form template object */ public function folder_acl_form() { $folder = rcube_utils::get_input_value('_folder', RCUBE_INPUT_GPC); if (strlen($folder)) { $storage = $this->rc->get_storage(); $options = $storage->folder_info($folder); // get sharing UI from acl plugin $acl = $this->rc->plugins->exec_hook('folder_form', array('form' => array(), 'options' => $options, 'name' => $folder)); } return $acl['form']['sharing']['content'] ?: html::div('hint', $this->plugin->gettext('aclnorights')); } }