Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F256708
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
28 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/plugins/tasklist/drivers/database/tasklist_database_driver.php b/plugins/tasklist/drivers/database/tasklist_database_driver.php
index b7ad8081..59bccc96 100644
--- a/plugins/tasklist/drivers/database/tasklist_database_driver.php
+++ b/plugins/tasklist/drivers/database/tasklist_database_driver.php
@@ -1,852 +1,852 @@
<?php
/**
* Database driver for the Tasklist plugin
*
* @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
*
* Copyright (C) 2012-2015, Kolab Systems AG <contact@kolabsys.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
class tasklist_database_driver extends tasklist_driver
{
const IS_COMPLETE_SQL = "(status='COMPLETED' OR (complete=1 AND status=''))";
public $undelete = true; // yes, we can
public $sortable = false;
public $alarm_types = array('DISPLAY');
private $rc;
private $plugin;
private $lists = array();
private $list_ids = '';
private $tags = array();
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)
return $this->rc->db->insert_id($this->db_lists);
return false;
}
/**
* Update properties of an existing tasklist
*
* @param array Hash array with list properties
* @return boolean True on success, Fales on failure
* @see tasklist_driver::edit_list()
*/
public function edit_list(&$prop)
{
$query = $this->rc->db->query(
"UPDATE " . $this->db_lists . "
SET name=?, color=?, showalarms=?
WHERE tasklist_id=?
AND user_id=?",
- $prop['name'],
- $prop['color'],
+ 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(sprintf(
"SELECT * FROM " . $this->db_tasks . "
WHERE tasklist_id IN (%s)
AND del=0
%s
ORDER BY parent_id, task_id ASC",
join(',', $list_ids),
$sql_add
),
$datefrom
);
while ($result && ($rec = $this->rc->db->fetch_assoc($result))) {
$tasks[] = $this->_read_postprocess($rec);
}
}
return $tasks;
}
/**
* Return data of a specific task
*
* @param mixed Hash array with task properties or task UID
* @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(sprintf(
"SELECT * FROM " . $this->db_tasks . "
WHERE tasklist_id IN (%s)
AND %s=?
AND del=0",
$this->list_ids,
$query_col
),
$prop['id'] ? $prop['id'] : $prop['uid']
);
if ($result && ($rec = $this->rc->db->fetch_assoc($result))) {
return $this->_read_postprocess($rec);
}
return false;
}
/**
* Get all decendents of the given task record
*
* @param mixed Hash array with task properties or task UID
* @param boolean True if all childrens children should be fetched
* @return array List of all child task IDs
*/
public function get_childs($prop, $recursive = false)
{
// resolve UID first
if (is_string($prop)) {
$result = $this->rc->db->query(sprintf(
"SELECT task_id AS id, tasklist_id AS list FROM " . $this->db_tasks . "
WHERE tasklist_id IN (%s)
AND uid=?",
$this->list_ids
),
$prop);
$prop = $this->rc->db->fetch_assoc($result);
}
$childs = array();
$task_ids = array($prop['id']);
// query for childs (recursively)
while (!empty($task_ids)) {
$result = $this->rc->db->query(sprintf(
"SELECT task_id AS id FROM " . $this->db_tasks . "
WHERE tasklist_id IN (%s)
AND parent_id IN (%s)
AND del=0",
$this->list_ids,
join(',', array_map(array($this->rc->db, 'quote'), $task_ids))
));
$task_ids = array();
while ($result && ($rec = $this->rc->db->fetch_assoc($result))) {
$childs[] = $rec['id'];
$task_ids[] = $rec['id'];
}
if (!$recursive)
break;
}
return $childs;
}
/**
* Get a list of pending alarms to be displayed to the user
*
* @param integer Current time (unix timestamp)
* @param mixed List of list IDs to show alarms for (either as array or comma-separated string)
* @return array A list of alarms, each encoded as hash array with task properties
* @see tasklist_driver::pending_alarms()
*/
public function pending_alarms($time, $lists = null)
{
if (empty($lists))
$lists = array_keys($this->lists);
else if (!is_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(sprintf(
"SELECT * FROM " . $this->db_tasks . "
WHERE tasklist_id IN (%s)
AND notify <= %s AND NOT " . self::IS_COMPLETE_SQL,
join(',', $list_ids),
$this->rc->db->fromunixtime($time)
));
while ($result && ($rec = $this->rc->db->fetch_assoc($result)))
$alarms[] = $this->_read_postprocess($rec);
}
return $alarms;
}
/**
* Feedback after showing/sending an alarm notification
*
* @see tasklist_driver::dismiss_alarm()
*/
public function dismiss_alarm($task_id, $snooze = 0)
{
// set new notifyat time or unset if not snoozed
$notify_at = $snooze > 0 ? date('Y-m-d H:i:s', time() + $snooze) : null;
$query = $this->rc->db->query(sprintf(
"UPDATE " . $this->db_tasks . "
SET changed=%s, notify=?
WHERE task_id=?
AND tasklist_id IN (" . $this->list_ids . ")",
$this->rc->db->now()),
$notify_at,
$task_id
);
return $this->rc->db->affected_rows($query);
}
/**
* Remove alarm dismissal or snooze state
*
* @param string Task identifier
*/
public function clear_alarms($id)
{
// Nothing to do here. Alarms are reset in edit_task()
}
/**
* Map some internal database values to match the generic "API"
*/
private function _read_postprocess($rec)
{
$rec['id'] = $rec['task_id'];
$rec['list'] = $rec['tasklist_id'];
$rec['changed'] = new DateTime($rec['changed']);
$rec['tags'] = array_filter(explode(',', $rec['tags']));
if (!$rec['parent_id'])
unset($rec['parent_id']);
// decode serialized alarms
if ($rec['alarms']) {
$rec['valarms'] = $this->unserialize_alarms($rec['alarms']);
unset($rec['alarms']);
}
// decode serialze recurrence rules
if ($rec['recurrence']) {
$rec['recurrence'] = $this->unserialize_recurrence($rec['recurrence']);
}
if (!empty($rec['tags'])) {
$this->tags = array_merge($this->tags, (array)$rec['tags']);
}
unset($rec['task_id'], $rec['tasklist_id'], $rec['created']);
return $rec;
}
/**
* Add a single task to the database
*
* @param array Hash array with task properties (see header of this file)
* @return mixed New event ID on success, False on error
* @see tasklist_driver::create_task()
*/
public function create_task($prop)
{
// check list permissions
$list_id = $prop['list'] ? $prop['list'] : reset(array_keys($this->lists));
if (!$this->lists[$list_id] || $this->lists[$list_id]['readonly'])
return false;
if (is_array($prop['valarms'])) {
$prop['alarms'] = $this->serialize_alarms($prop['valarms']);
}
if (is_array($prop['recurrence'])) {
$prop['recurrence'] = $this->serialize_recurrence($prop['recurrence']);
}
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);
$result = $this->rc->db->query(sprintf(
"INSERT INTO " . $this->db_tasks . "
(tasklist_id, uid, parent_id, created, changed, title, date, time, startdate, starttime, description, tags, flagged, complete, status, alarms, recurrence, notify)
VALUES (?, ?, ?, %s, %s, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
$this->rc->db->now(),
$this->rc->db->now()
),
$list_id,
$prop['uid'],
$prop['parent_id'],
$prop['title'],
$prop['date'],
$prop['time'],
$prop['startdate'],
$prop['starttime'],
strval($prop['description']),
join(',', (array)$prop['tags']),
$prop['flagged'] ? 1 : 0,
intval($prop['complete']),
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', 'status') as $col) {
if (isset($prop[$col]))
$sql_set[] = $this->rc->db->quote_identifier($col) . '=' . (empty($prop[$col]) ? 'NULL' : $this->rc->db->quote($prop[$col]));
}
if (isset($prop['tags']))
$sql_set[] = $this->rc->db->quote_identifier('tags') . '=' . $this->rc->db->quote(join(',', (array)$prop['tags']));
if (isset($prop['date']) || isset($prop['time']) || isset($prop['alarms'])) {
$notify_at = $this->_get_notification($prop);
$sql_set[] = $this->rc->db->quote_identifier('notify') . '=' . (empty($notify_at) ? 'NULL' : $this->rc->db->quote($notify_at));
}
// moved from another list
if ($prop['_fromlist'] && ($newlist = $prop['list'])) {
$sql_set[] = 'tasklist_id=' . $this->rc->db->quote($newlist);
}
$query = $this->rc->db->query(sprintf(
"UPDATE " . $this->db_tasks . "
SET changed=%s %s
WHERE task_id=?
AND tasklist_id IN (%s)",
$this->rc->db->now(),
($sql_set ? ', ' . join(', ', $sql_set) : ''),
$this->list_ids
),
$prop['id']
);
return $this->rc->db->affected_rows($query);
}
/**
* Move a single task to another list
*
* @param array Hash array with task properties:
* @return boolean True on success, False on error
* @see tasklist_driver::move_task()
*/
public function move_task($prop)
{
return $this->edit_task($prop);
}
/**
* Remove a single task from the database
*
* @param array Hash array with task properties
* @param boolean Remove record irreversible
* @return boolean True on success, False on error
* @see tasklist_driver::delete_task()
*/
public function delete_task($prop, $force = true)
{
$task_id = $prop['id'];
if ($task_id && $force) {
$query = $this->rc->db->query(
"DELETE FROM " . $this->db_tasks . "
WHERE task_id=?
AND tasklist_id IN (" . $this->list_ids . ")",
$task_id
);
}
else if ($task_id) {
$query = $this->rc->db->query(sprintf(
"UPDATE " . $this->db_tasks . "
SET changed=%s, del=1
WHERE task_id=?
AND tasklist_id IN (%s)",
$this->rc->db->now(),
$this->list_ids
),
$task_id
);
}
return $this->rc->db->affected_rows($query);
}
/**
* Restores a single deleted task (if supported)
*
* @param array Hash array with task properties
* @return boolean True on success, False on error
* @see tasklist_driver::undelete_task()
*/
public function undelete_task($prop)
{
$query = $this->rc->db->query(sprintf(
"UPDATE " . $this->db_tasks . "
SET changed=%s, del=0
WHERE task_id=?
AND tasklist_id IN (%s)",
$this->rc->db->now(),
$this->list_ids
),
$prop['id']
);
return $this->rc->db->affected_rows($query);
}
/**
* Compute absolute time to notify the user
*/
private function _get_notification($task)
{
if ($task['valarms'] && !$this->is_complete($task)) {
$alarm = libcalendaring::get_next_alarm($task, 'task');
if ($alarm['time'] && in_array($alarm['action'], $this->alarm_types))
return date('Y-m-d H:i:s', $alarm['time']);
}
return null;
}
/**
* Helper method to serialize the list of alarms into a string
*/
private function serialize_alarms($valarms)
{
foreach ((array)$valarms as $i => $alarm) {
if ($alarm['trigger'] instanceof DateTime) {
$valarms[$i]['trigger'] = '@' . $alarm['trigger']->format('c');
}
}
return $valarms ? json_encode($valarms) : null;
}
/**
* Helper method to decode a serialized list of alarms
*/
private function unserialize_alarms($alarms)
{
// decode json serialized alarms
if ($alarms && $alarms[0] == '[') {
$valarms = json_decode($alarms, true);
foreach ($valarms as $i => $alarm) {
if ($alarm['trigger'][0] == '@') {
try {
$valarms[$i]['trigger'] = new DateTime(substr($alarm['trigger'], 1));
}
catch (Exception $e) {
unset($valarms[$i]);
}
}
}
}
// convert legacy alarms data
else if (strlen($alarms)) {
list($trigger, $action) = explode(':', $alarms, 2);
if ($trigger = libcalendaring::parse_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;
$list_ids = array();
$lists = $db->query("SELECT tasklist_id FROM " . $this->db_lists . " WHERE user_id=?", $args['user']->ID);
while ($row = $db->fetch_assoc($lists)) {
$list_ids[] = $row['tasklist_id'];
}
if (!empty($list_ids)) {
foreach (array($this->db_tasks, $this->db_lists) as $table) {
$db->query(sprintf("DELETE FROM $table WHERE tasklist_id IN (%s)", join(',', $list_ids)));
}
}
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Mon, Jun 9, 7:36 AM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
196776
Default Alt Text
(28 KB)
Attached To
Mode
R14 roundcubemail-plugins-kolab
Attached
Detach File
Event Timeline
Log In to Comment