CalDAV driver for Tasks

includes cache syncronization fixes and PHP8 fixes.
This commit is contained in:
Aleksander Machniak 2023-01-03 10:42:54 +01:00
parent ca07e581dd
commit a3ef1eedf1
19 changed files with 2007 additions and 128 deletions

View file

@ -687,6 +687,6 @@ class caldav_driver extends kolab_driver
], ],
]; ];
return kolab_utils::folder_form($form, $folder, 'calendar', [], true); return kolab_utils::folder_form($form, '', 'calendar', [], true);
} }
} }

View file

@ -30,6 +30,9 @@ class calendar_ui
private $ready = false; private $ready = false;
public $screen; public $screen;
public $action;
public $calendar;
function __construct($cal) function __construct($cal)
{ {

View file

@ -33,8 +33,8 @@ use \Sabre\VObject\DateTimeParser;
class libcalendaring_vcalendar implements Iterator class libcalendaring_vcalendar implements Iterator
{ {
private $timezone; private $timezone;
private $attach_uri = null; private $attach_uri;
private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; private $prodid;
private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO');
private $attendee_keymap = array( private $attendee_keymap = array(
'name' => 'CN', 'name' => 'CN',
@ -73,7 +73,7 @@ class libcalendaring_vcalendar implements Iterator
function __construct($tz = null) function __construct($tz = null)
{ {
$this->timezone = $tz; $this->timezone = $tz;
$this->prodid = '-//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; $this->prodid = '-//Roundcube ' . RCUBE_VERSION . '//Sabre VObject ' . VObject\Version::VERSION . '//EN';
} }
/** /**

View file

@ -210,6 +210,24 @@ CREATE TABLE `kolab_cache_dav_event` (
PRIMARY KEY(`folder_id`,`uid`) PRIMARY KEY(`folder_id`,`uid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
DROP TABLE IF EXISTS `kolab_cache_dav_task`;
CREATE TABLE `kolab_cache_dav_task` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`etag` VARCHAR(128) DEFAULT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`dtstart` DATETIME,
`dtend` DATETIME,
CONSTRAINT `fk_kolab_cache_dav_task_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`uid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS=1; SET FOREIGN_KEY_CHECKS=1;
REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022100500'); REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2022122800');

View file

@ -0,0 +1,17 @@
DROP TABLE IF EXISTS `kolab_cache_dav_task`;
CREATE TABLE `kolab_cache_dav_task` (
`folder_id` BIGINT UNSIGNED NOT NULL,
`uid` VARCHAR(512) NOT NULL,
`etag` VARCHAR(128) DEFAULT NULL,
`created` DATETIME DEFAULT NULL,
`changed` DATETIME DEFAULT NULL,
`data` LONGTEXT NOT NULL,
`tags` TEXT NOT NULL,
`words` TEXT NOT NULL,
`dtstart` DATETIME,
`dtend` DATETIME,
CONSTRAINT `fk_kolab_cache_dav_task_folder` FOREIGN KEY (`folder_id`)
REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY(`folder_id`,`uid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View file

@ -264,9 +264,9 @@ class kolab_dav_client
foreach ($response->getElementsByTagName('response') as $element) { foreach ($response->getElementsByTagName('response') as $element) {
$folder = $this->getFolderPropertiesFromResponse($element); $folder = $this->getFolderPropertiesFromResponse($element);
// Note: Addressbooks don't have 'type' specified // Note: Addressbooks don't have 'types' specified
if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type'])) if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type']))
|| $folder['type'] === $component || in_array($component, (array) $folder['types'])
) { ) {
$folders[] = $folder; $folders[] = $folder;
} }
@ -296,18 +296,7 @@ class kolab_dav_client
$response = $this->request($location, 'PUT', $content, $headers); $response = $this->request($location, 'PUT', $content, $headers);
if ($response !== false) { return $this->getETagFromResponse($response);
// Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456
$etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null;
if (is_string($etag) && preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
return $etag;
}
return false;
} }
/** /**
@ -338,6 +327,23 @@ class kolab_dav_client
return $response !== false; return $response !== false;
} }
/**
* Move a DAV object
*
* @param string $source Source object location
* @param string $target Target object content
*
* @return false|string|null ETag string (or NULL) on success, False on error
*/
public function move($source, $target)
{
$headers = ['Destination' => $target];
$response = $this->request($source, 'MOVE', '', $headers);
return $this->getETagFromResponse($response);
}
/** /**
* Get folder properties. * Get folder properties.
* *
@ -665,10 +671,10 @@ class kolab_dav_client
$ctag = $ctag->nodeValue; $ctag = $ctag->nodeValue;
} }
$component = null; $components = [];
if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) { foreach ($set_element->getElementsByTagName('comp') as $comp_element) {
$component = $comp_element->attributes->getNamedItem('name')->nodeValue; $components[] = $comp_element->attributes->getNamedItem('name')->nodeValue;
} }
} }
@ -685,7 +691,7 @@ class kolab_dav_client
'name' => $name, 'name' => $name,
'ctag' => $ctag, 'ctag' => $ctag,
'color' => $color, 'color' => $color,
'type' => $component, 'types' => $components,
'resource_type' => $types, 'resource_type' => $types,
]; ];
@ -741,6 +747,25 @@ class kolab_dav_client
]; ];
} }
/**
* Get ETag from a response
*/
protected function getETagFromResponse($response)
{
if ($response !== false) {
// Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456
$etag = isset($this->responseHeaders['etag']) ? $this->responseHeaders['etag'] : null;
if (is_string($etag) && preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
return $etag;
}
return false;
}
/** /**
* Initialize HTTP request object * Initialize HTTP request object
*/ */

View file

@ -751,7 +751,7 @@ abstract class kolab_format
} }
// in kolab_storage attachments are indexed by content-id // in kolab_storage attachments are indexed by content-id
foreach ((array) $object['attachments'] as $attachment) { foreach ((array) ($object['attachments'] ?? []) as $attachment) {
$key = null; $key = null;
// Roundcube ID has nothing to do with the storage ID, remove it // Roundcube ID has nothing to do with the storage ID, remove it

View file

@ -92,8 +92,9 @@ class kolab_storage_cache
$rcmail->add_shutdown_function(array($this, '_sync_unlock')); $rcmail->add_shutdown_function(array($this, '_sync_unlock'));
} }
if ($storage_folder) if ($storage_folder) {
$this->set_folder($storage_folder); $this->set_folder($storage_folder);
}
} }
/** /**
@ -1261,15 +1262,13 @@ class kolab_storage_cache
$read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?"; $read_query = "SELECT `synclock`, `ctag` FROM `{$this->folders_table}` WHERE `folder_id` = ?";
$write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?"; $write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?";
$max_lock_time = $this->_max_sync_lock_time(); $max_lock_time = $this->_max_sync_lock_time();
$sync_lock = intval($this->metadata['synclock'] ?? 0);
// wait if locked (expire locks after 10 minutes) ... // wait if locked (expire locks after 10 minutes) ...
// ... or if setting lock fails (another process meanwhile set it) // ... or if setting lock fails (another process meanwhile set it)
while ( while (
($sync_lock + $max_lock_time > time()) || (intval($this->metadata['synclock'] ?? 0) + $max_lock_time > time()) ||
(($res = $this->db->query($write_query, time(), $this->folder_id, $sync_lock)) (($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock'] ?? 0)))
&& !($affected = $this->db->affected_rows($res)) && !($affected = $this->db->affected_rows($res))
) )
) { ) {

View file

@ -157,9 +157,10 @@ class kolab_storage_dav_cache extends kolab_storage_cache
} }
} }
$i = 0;
// Fetch new objects and store in DB // Fetch new objects and store in DB
if (!empty($new_index)) { if (!empty($new_index)) {
$i = 0;
foreach (array_chunk($new_index, $chunk_size, true) as $chunk) { foreach (array_chunk($new_index, $chunk_size, true) as $chunk) {
$objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk); $objects = $this->folder->dav->getData($this->folder->href, $this->folder->get_dav_type(), $chunk);
@ -707,4 +708,38 @@ class kolab_storage_dav_cache extends kolab_storage_cache
return $object; return $object;
} }
/**
* Read this folder's ID and cache metadata
*/
protected function _read_folder_data()
{
// already done
if (!empty($this->folder_id) || !$this->ready) {
return;
}
// Different than in Kolab XML-based storage, in *DAV folders can
// contain different types of data, e.g. Calendar can store events and tasks.
// Therefore we both `resource` and `type` in WHERE.
$sql_arr = $this->db->fetch_assoc($this->db->query(
"SELECT `folder_id`, `synclock`, `ctag`, `changed` FROM `{$this->folders_table}`"
. " WHERE `resource` = ? AND `type` = ?",
$this->resource_uri,
$this->folder->type
));
if ($sql_arr) {
$this->folder_id = $sql_arr['folder_id'];
$this->metadata = $sql_arr;
}
else {
$this->db->query("INSERT INTO `{$this->folders_table}` (`resource`, `type`)"
. " VALUES (?, ?)", $this->resource_uri, $this->folder->type);
$this->folder_id = $this->db->insert_id('kolab_folders');
$this->metadata = [];
}
}
} }

View file

@ -0,0 +1,112 @@
<?php
/**
* Kolab storage cache class for task objects
*
* @author Aleksander Machniak <machniak@apheleia-it.ch>
*
* Copyright (C) 2013-2022 Apheleia IT AG <contact@apheleia-it.ch>
*
* 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 kolab_storage_dav_cache_task extends kolab_storage_dav_cache
{
protected $extra_cols = ['dtstart','dtend'];
protected $data_props = ['categories', 'status', 'complete', 'start', 'due'];
protected $fulltext_cols = ['title', 'description', 'categories'];
/**
* Helper method to convert the given Kolab object into a dataset to be written to cache
*
* @override
*/
protected function _serialize($object)
{
$sql_data = parent::_serialize($object);
$sql_data['dtstart'] = !empty($object['start']) ? $this->_convert_datetime($object['start']) : null;
$sql_data['dtend'] = !empty($object['due']) ? $this->_convert_datetime($object['due']) : null;
$sql_data['tags'] = ' ' . join(' ', $this->get_tags($object)) . ' '; // pad with spaces for strict/prefix search
$sql_data['words'] = ' ' . join(' ', $this->get_words($object)) . ' ';
return $sql_data;
}
/**
* Callback to get words to index for fulltext search
*
* @return array List of words to save in cache
*/
public function get_words($object = [])
{
$data = '';
foreach ($this->fulltext_cols as $colname) {
list($col, $field) = strpos($colname, ':') ? explode(':', $colname) : [$colname, null];
if (empty($object[$col])) {
continue;
}
if ($field) {
$a = [];
foreach ((array) $object[$col] as $attr) {
if (!empty($attr[$field])) {
$a[] = $attr[$field];
}
}
$val = join(' ', $a);
}
else {
$val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col];
}
if (is_string($val) && strlen($val)) {
$data .= $val . ' ';
}
}
$words = rcube_utils::normalize_string($data, true);
return array_unique($words);
}
/**
* Callback to get object specific tags to cache
*
* @return array List of tags to save in cache
*/
public function get_tags($object)
{
$tags = [];
if ((isset($object['status']) && $object['status'] == 'COMPLETED')
|| (isset($object['complete']) && $object['complete'] == 100 && empty($object['status']))
) {
$tags[] = 'x-complete';
}
if (!empty($object['priority']) && $object['priority'] == 1) {
$tags[] = 'x-flagged';
}
if (!empty($object['parent_id'])) {
$tags[] = 'x-parent:' . $object['parent_id'];
}
return array_unique($tags);
}
}

View file

@ -20,6 +20,8 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
#[AllowDynamicProperties]
class kolab_storage_dav_folder extends kolab_storage_folder class kolab_storage_dav_folder extends kolab_storage_folder
{ {
public $dav; public $dav;
@ -310,8 +312,8 @@ class kolab_storage_dav_folder extends kolab_storage_folder
/** /**
* Move a Kolab object message to another IMAP folder * Move a Kolab object message to another IMAP folder
* *
* @param string Object UID * @param string Object UID
* @param string IMAP folder to move object to * @param kolab_storage_dav_folder Target folder to move object into
* *
* @return bool True on success, false on failure * @return bool True on success, false on failure
*/ */
@ -321,9 +323,16 @@ class kolab_storage_dav_folder extends kolab_storage_folder
return false; return false;
} }
// TODO $source = $this->object_location($uid);
$target = $target_folder->object_location($uid);
return false; $success = $this->dav->move($source, $target) !== false;
if ($success) {
$this->cache->set($uid, false);
}
return $success;
} }
/** /**
@ -466,15 +475,15 @@ class kolab_storage_dav_folder extends kolab_storage_folder
return false; return false;
} }
if ($this->type == 'event') { if ($this->type == 'event' || $this->type == 'task') {
$ical = libcalendaring::get_ical(); $ical = libcalendaring::get_ical();
$events = $ical->import($object['data']); $objects = $ical->import($object['data']);
if (!count($events) || empty($events[0]['uid'])) { if (!count($objects) || empty($objects[0]['uid'])) {
return false; return false;
} }
$result = $events[0]; $result = $objects[0];
$result['_attachments'] = $result['attachments'] ?? []; $result['_attachments'] = $result['attachments'] ?? [];
unset($result['attachments']); unset($result['attachments']);
@ -550,12 +559,15 @@ class kolab_storage_dav_folder extends kolab_storage_folder
{ {
$result = ''; $result = '';
if ($this->type == 'event') { if ($this->type == 'event' || $this->type == 'task') {
$ical = libcalendaring::get_ical(); $ical = libcalendaring::get_ical();
if (!empty($object['exceptions'])) { if (!empty($object['exceptions'])) {
$object['recurrence']['EXCEPTIONS'] = $object['exceptions']; $object['recurrence']['EXCEPTIONS'] = $object['exceptions'];
} }
$object['_type'] = $this->type;
// pre-process attachments // pre-process attachments
if (isset($object['_attachments']) && is_array($object['_attachments'])) { if (isset($object['_attachments']) && is_array($object['_attachments'])) {
foreach ($object['_attachments'] as $key => $attachment) { foreach ($object['_attachments'] as $key => $attachment) {
@ -669,7 +681,7 @@ class kolab_storage_dav_folder extends kolab_storage_folder
return $result; return $result;
} }
protected function object_location($uid) public function object_location($uid)
{ {
return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext();
} }

View file

@ -1,7 +1,7 @@
A task management module for Roundcube A task management module for Roundcube
-------------------------------------- --------------------------------------
This plugin currently supports a local database as well as a Kolab groupware This plugin currently supports a local database, CalDAV server or a Kolab groupware
server as backends for tasklists and todo items storage. server as backends for tasklists and todo items storage.
@ -43,7 +43,7 @@ driver.
$ cd ../../ $ cd ../../
$ bin/initdb.sh --dir=plugins/tasklist/drivers/database/SQL $ bin/initdb.sh --dir=plugins/tasklist/drivers/database/SQL
4. Build css styles for the Elastic skin 4. Build css styles for the Elastic skin (if needed)
$ lessc --relative-urls -x plugins/libkolab/skins/elastic/libkolab.less > plugins/libkolab/skins/elastic/libkolab.min.css $ lessc --relative-urls -x plugins/libkolab/skins/elastic/libkolab.less > plugins/libkolab/skins/elastic/libkolab.min.css

View file

@ -1,8 +1,11 @@
<?php <?php
// backend type (database, kolab) // backend type (database, kolab, caldav)
$config['tasklist_driver'] = 'kolab'; $config['tasklist_driver'] = 'kolab';
// CalDAV server location (required when tasklist_driver = caldav)
$config['tasklist_caldav_server'] = "http://localhost";
// default sorting order of tasks listing (auto, datetime, startdatetime, flagged, complete, changed) // default sorting order of tasks listing (auto, datetime, startdatetime, flagged, complete, changed)
$config['tasklist_sort_col'] = ''; $config['tasklist_sort_col'] = '';

File diff suppressed because it is too large Load diff

View file

@ -75,7 +75,7 @@ abstract class tasklist_driver
public $attendees = false; public $attendees = false;
public $undelete = false; // task undelete action public $undelete = false; // task undelete action
public $sortable = false; public $sortable = false;
public $alarm_types = array('DISPLAY'); public $alarm_types = ['DISPLAY'];
public $alarm_absolute = true; public $alarm_absolute = true;
public $last_error; public $last_error;
@ -331,7 +331,7 @@ abstract class tasklist_driver
public function get_message_related_tasks($headers, $folder) public function get_message_related_tasks($headers, $folder)
{ {
// to be implemented by the derived classes // to be implemented by the derived classes
return array(); return [];
} }
/** /**
@ -342,7 +342,8 @@ abstract class tasklist_driver
*/ */
public function is_complete($task) public function is_complete($task)
{ {
return ($task['complete'] >= 1.0 && empty($task['status'])) || $task['status'] === 'COMPLETED'; return (isset($task['complete']) && $task['complete'] >= 1.0 && empty($task['status']))
|| (!empty($task['status']) && $task['status'] === 'COMPLETED');
} }
/** /**
@ -428,7 +429,7 @@ abstract class tasklist_driver
*/ */
public function tasklist_edit_form($action, $list, $formfields) public function tasklist_edit_form($action, $list, $formfields)
{ {
$table = new html_table(array('cols' => 2, 'class' => 'propform')); $table = new html_table(['cols' => 2, 'class' => 'propform']);
foreach ($formfields as $col => $colprop) { foreach ($formfields as $col => $colprop) {
$label = !empty($colprop['label']) ? $colprop['label'] : $rcmail->gettext("$domain.$col"); $label = !empty($colprop['label']) ? $colprop['label'] : $rcmail->gettext("$domain.$col");
@ -446,13 +447,13 @@ abstract class tasklist_driver
public function tasklist_caldav_url($list) public function tasklist_caldav_url($list)
{ {
$rcmail = rcube::get_instance(); $rcmail = rcube::get_instance();
if (!empty($list['caldavuid']) && ($template = $rcmail->config->get('calendar_caldav_url', null))) { if (!empty($list['caldavuid']) && ($template = $rcmail->config->get('calendar_caldav_url'))) {
return strtr($template, array( return strtr($template, [
'%h' => $_SERVER['HTTP_HOST'], '%h' => $_SERVER['HTTP_HOST'],
'%u' => urlencode($rcmail->get_user_name()), '%u' => urlencode($rcmail->get_user_name()),
'%i' => urlencode($list['caldavuid']), '%i' => urlencode($list['caldavuid']),
'%n' => urlencode($list['editname']), '%n' => urlencode($list['editname']),
)); ]);
} }
return null; return null;

View file

@ -99,7 +99,9 @@
<legend><roundcube:label name="tasklist.tabassignments" /></legend> <legend><roundcube:label name="tasklist.tabassignments" /></legend>
<div class="form-group row" id="taskedit-organizer"> <div class="form-group row" id="taskedit-organizer">
<label for="edit-identities-list" class="col-form-label col-sm-2"><roundcube:label name="tasklist.roleorganizer" /></label> <label for="edit-identities-list" class="col-form-label col-sm-2"><roundcube:label name="tasklist.roleorganizer" /></label>
<roundcube:object name="plugin.identity_select" id="edit-identities-list" class="col-sm-10 form-control" /> <span class="col-sm-10">
<roundcube:object name="plugin.identity_select" id="edit-identities-list" />
</span>
</div> </div>
<h3 id="aria-label-attendeestable" class="voice"><roundcube:label name="tasklist.arialabeleventassignments" /></h3> <h3 id="aria-label-attendeestable" class="voice"><roundcube:label name="tasklist.arialabeleventassignments" /></h3>
<roundcube:object name="plugin.attendees_list" id="edit-attendees-table" class="edit-attendees-table no-img table table-sm" <roundcube:object name="plugin.attendees_list" id="edit-attendees-table" class="edit-attendees-table no-img table table-sm"

View file

@ -296,7 +296,7 @@ function rcube_tasklist_ui(settings)
rcmail.addEventListener('plugin.update_tasklist', update_list); rcmail.addEventListener('plugin.update_tasklist', update_list);
rcmail.addEventListener('plugin.destroy_tasklist', destroy_list); rcmail.addEventListener('plugin.destroy_tasklist', destroy_list);
rcmail.addEventListener('plugin.unlock_saving', unlock_saving); rcmail.addEventListener('plugin.unlock_saving', unlock_saving);
rcmail.addEventListener('plugin.refresh_tagcloud', function() { update_tagcloud(); }); rcmail.addEventListener('plugin.refresh_tagcloud', function() { update_taglist(); });
rcmail.addEventListener('requestrefresh', before_refresh); rcmail.addEventListener('requestrefresh', before_refresh);
rcmail.addEventListener('plugin.reload_data', function(){ rcmail.addEventListener('plugin.reload_data', function(){
list_tasks(null, true); list_tasks(null, true);
@ -2755,10 +2755,10 @@ function rcube_tasklist_ui(settings)
*/ */
function task_show_attachments(list, container, task, edit) function task_show_attachments(list, container, task, edit)
{ {
libkolab.list_attachments(list, container, edit, task, libkolab.list_attachments(list, container, edit, task,
function(id) { remove_attachment(id); }, function(id) { remove_attachment(id); },
function(data) { load_attachment(data); } function(data) { load_attachment(data); }
); );
}; };
/** /**

View file

@ -22,6 +22,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
#[AllowDynamicProperties]
class tasklist extends rcube_plugin class tasklist extends rcube_plugin
{ {
const FILTER_MASK_TODAY = 1; const FILTER_MASK_TODAY = 1;
@ -206,7 +207,8 @@ class tasklist extends rcube_plugin
$action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC);
$rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true); $rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true);
$oldrec = $rec; $oldrec = $rec;
$success = $refresh = $got_msg = false; $success = $got_msg = false;
$refresh = [];
// force notify if hidden + active // force notify if hidden + active
$itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3);
@ -284,8 +286,7 @@ class tasklist extends rcube_plugin
foreach ((array)$rec['id'] as $id) { foreach ((array)$rec['id'] as $id) {
$r = $rec; $r = $rec;
$r['id'] = $id; $r['id'] = $id;
if ($this->driver->move_task($r)) { if ($this->driver->move_task($r) && ($new_task = $this->driver->get_task($r))) {
$new_task = $this->driver->get_task($r);
$new_task['tempid'] = $id; $new_task['tempid'] = $id;
$refresh[] = $new_task; $refresh[] = $new_task;
$success = true; $success = true;
@ -330,7 +331,7 @@ class tasklist extends rcube_plugin
// update parent task to adjust list of children // update parent task to adjust list of children
if (!empty($oldrec['parent_id'])) { if (!empty($oldrec['parent_id'])) {
$parent = array('id' => $oldrec['parent_id'], 'list' => $rec['list']); $parent = array('id' => $oldrec['parent_id'], 'list' => $rec['list']);
if ($parent = $this->driver->get_task()) { if ($parent = $this->driver->get_task($parent)) {
$refresh[] = $parent; $refresh[] = $parent;
} }
} }
@ -547,23 +548,26 @@ class tasklist extends rcube_plugin
$itip = $this->load_itip(); $itip = $this->load_itip();
$itip->set_sender_email($sender['email']); $itip->set_sender_email($sender['email']);
if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) {
$this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation'); $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation');
else }
else {
$this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
}
} }
} }
// unlock client // unlock client
$this->rc->output->command('plugin.unlock_saving', $success); $this->rc->output->command('plugin.unlock_saving', $success);
if ($refresh) { if (!empty($refresh)) {
if (!empty($refresh['id'])) { if (!empty($refresh['id'])) {
$this->encode_task($refresh); $this->encode_task($refresh);
} }
else if (is_array($refresh)) { else if (is_array($refresh)) {
foreach ($refresh as $i => $r) foreach ($refresh as $i => $r) {
$this->encode_task($refresh[$i]); $this->encode_task($refresh[$i]);
}
} }
$this->rc->output->command('plugin.update_task', $refresh); $this->rc->output->command('plugin.update_task', $refresh);
} }
@ -688,13 +692,13 @@ class tasklist extends rcube_plugin
} }
// convert the submitted recurrence settings // convert the submitted recurrence settings
if (is_array($rec['recurrence'])) { if (isset($rec['recurrence']) && is_array($rec['recurrence'])) {
$refdate = null; $refdate = null;
if (!empty($rec['date'])) { if (!empty($rec['date'])) {
$refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); $refdate = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone);
} }
else if (!empty($rec['startdate'])) { else if (!empty($rec['startdate'])) {
$refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); $refdate = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone);
} }
if ($refdate) { if ($refdate) {
@ -732,7 +736,7 @@ class tasklist extends rcube_plugin
if (!empty($rec['attendees'])) { if (!empty($rec['attendees'])) {
foreach ((array) $rec['attendees'] as $i => $attendee) { foreach ((array) $rec['attendees'] as $i => $attendee) {
if (is_string($attendee['rsvp'])) { if (isset($attendee['rsvp']) && is_string($attendee['rsvp'])) {
$rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1';
} }
} }
@ -768,16 +772,17 @@ class tasklist extends rcube_plugin
try { try {
// parse date from user format (#2801) // parse date from user format (#2801)
$date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d'); $date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d');
$date = DateTime::createFromFormat($date_format, trim($rec[$date_key] . ' ' . $rec[$time_key]), $this->timezone); $date = DateTime::createFromFormat($date_format, trim(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? '')), $this->timezone);
// fall back to default strtotime logic // fall back to default strtotime logic
if (empty($date)) { if (empty($date)) {
$date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); $date = new DateTime(($rec[$date_key] ?? '') . ' ' . ($rec[$time_key] ?? ''), $this->timezone);
} }
$rec[$date_key] = $date->format('Y-m-d'); $rec[$date_key] = $date->format('Y-m-d');
if (!empty($rec[$time_key])) if (!empty($rec[$time_key])) {
$rec[$time_key] = $date->format('H:i'); $rec[$time_key] = $date->format('H:i');
}
return true; return true;
} }
@ -804,7 +809,7 @@ class tasklist extends rcube_plugin
private function handle_recurrence(&$rec, $old) private function handle_recurrence(&$rec, $old)
{ {
$clone = null; $clone = null;
if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && is_array($rec['recurrence'])) { if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && !empty($rec['recurrence'])) {
$engine = libcalendaring::get_recurrence(); $engine = libcalendaring::get_recurrence();
$rrule = $rec['recurrence']; $rrule = $rec['recurrence'];
$updates = array(); $updates = array();
@ -814,12 +819,13 @@ class tasklist extends rcube_plugin
if (empty($rec[$date_key])) if (empty($rec[$date_key]))
continue; continue;
$date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); $date = new DateTime($rec[$date_key] . ' ' . ($rec[$time_key] ?? ''), $this->timezone);
$engine->init($rrule, $date); $engine->init($rrule, $date);
if ($next = $engine->next_start()) { if ($next = $engine->next_start()) {
$updates[$date_key] = $next->format('Y-m-d'); $updates[$date_key] = $next->format('Y-m-d');
if (!empty($rec[$time_key])) if (!empty($rec[$time_key])) {
$updates[$time_key] = $next->format('H:i'); $updates[$time_key] = $next->format('H:i');
}
} }
} }
@ -1174,15 +1180,15 @@ class tasklist extends rcube_plugin
*/ */
private function encode_task(&$rec) private function encode_task(&$rec)
{ {
$rec['mask'] = $this->filter_mask($rec); $rec['mask'] = $this->filter_mask($rec);
$rec['flagged'] = intval($rec['flagged']); $rec['flagged'] = intval($rec['flagged'] ?? 0);
$rec['complete'] = floatval($rec['complete']); $rec['complete'] = floatval($rec['complete'] ?? 0);
if (is_object($rec['created'])) { if (!empty($rec['created']) && is_object($rec['created'])) {
$rec['created_'] = $this->rc->format_date($rec['created']); $rec['created_'] = $this->rc->format_date($rec['created']);
$rec['created'] = $rec['created']->format('U'); $rec['created'] = $rec['created']->format('U');
} }
if (is_object($rec['changed'])) { if (!empty($rec['changed']) && is_object($rec['changed'])) {
$rec['changed_'] = $this->rc->format_date($rec['changed']); $rec['changed_'] = $this->rc->format_date($rec['changed']);
$rec['changed'] = $rec['changed']->format('U'); $rec['changed'] = $rec['changed']->format('U');
} }
@ -1190,9 +1196,9 @@ class tasklist extends rcube_plugin
$rec['changed'] = null; $rec['changed'] = null;
} }
if ($rec['date']) { if (!empty($rec['date'])) {
try { try {
$date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); $date = new DateTime($rec['date'] . ' ' . ($rec['time'] ?? ''), $this->timezone);
$rec['datetime'] = intval($date->format('U')); $rec['datetime'] = intval($date->format('U'));
$rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d'));
$rec['_hasdate'] = 1; $rec['_hasdate'] = 1;
@ -1206,9 +1212,9 @@ class tasklist extends rcube_plugin
$rec['_hasdate'] = 0; $rec['_hasdate'] = 0;
} }
if ($rec['startdate']) { if (!empty($rec['startdate'])) {
try { try {
$date = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); $date = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone);
$rec['startdatetime'] = intval($date->format('U')); $rec['startdatetime'] = intval($date->format('U'));
$rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); $rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d'));
} }
@ -1224,12 +1230,13 @@ class tasklist extends rcube_plugin
if (!empty($rec['recurrence'])) { if (!empty($rec['recurrence'])) {
$rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']); $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']);
$rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], $rec['time'] || $rec['starttime']); $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], !empty($rec['time']) || !empty($rec['starttime']));
} }
if (!empty($rec['attachments'])) { if (!empty($rec['attachments'])) {
foreach ((array) $rec['attachments'] as $k => $attachment) { foreach ((array) $rec['attachments'] as $k => $attachment) {
$rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']);
unset($rec['attachments'][$k]['data']);
} }
} }
@ -1248,17 +1255,21 @@ class tasklist extends rcube_plugin
$rec['description'] = $h2t->get_text(); $rec['description'] = $h2t->get_text();
} }
if (!is_array($rec['tags'])) if (!isset($rec['tags']) || !is_array($rec['tags'])) {
$rec['tags'] = (array)$rec['tags']; $rec['tags'] = (array) ($rec['tags'] ?? '');
}
sort($rec['tags'], SORT_LOCALE_STRING); sort($rec['tags'], SORT_LOCALE_STRING);
if (in_array($rec['id'], $this->collapsed_tasks)) if (in_array($rec['id'], $this->collapsed_tasks)) {
$rec['collapsed'] = true; $rec['collapsed'] = true;
}
if (empty($rec['parent_id'])) if (empty($rec['parent_id'])) {
$rec['parent_id'] = null; $rec['parent_id'] = null;
}
$this->task_titles[$rec['id']] = $rec['title']; $this->task_titles[$rec['id']] = $rec['title'] ?? '';
} }
/** /**
@ -1267,7 +1278,9 @@ class tasklist extends rcube_plugin
private function is_html($task) private function is_html($task)
{ {
// check for opening and closing <html> or <body> tags // check for opening and closing <html> or <body> tags
return (preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '</'.$m[1].'>') > 0); return isset($task['description'])
&& preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m)
&& strpos($task['description'], '</' . $m[1] . '>') > 0;
} }
/** /**
@ -1303,9 +1316,9 @@ class tasklist extends rcube_plugin
static $today, $today_date, $tomorrow, $weeklimit; static $today, $today_date, $tomorrow, $weeklimit;
if (!$today) { if (!$today) {
$today_date = new DateTime('now', $this->timezone); $today_date = new libcalendaring_datetime('now', $this->timezone);
$today = $today_date->format('Y-m-d'); $today = $today_date->format('Y-m-d');
$tomorrow_date = new DateTime('now + 1 day', $this->timezone); $tomorrow_date = new libcalendaring_datetime('now + 1 day', $this->timezone);
$tomorrow = $tomorrow_date->format('Y-m-d'); $tomorrow = $tomorrow_date->format('Y-m-d');
// In Kolab-mode we hide "Next 7 days" filter, which means // In Kolab-mode we hide "Next 7 days" filter, which means
@ -1320,21 +1333,25 @@ class tasklist extends rcube_plugin
} }
$mask = 0; $mask = 0;
$start = $rec['startdate'] ?: '1900-00-00'; $start = !empty($rec['startdate']) ? $rec['startdate'] : '1900-00-00';
$duedate = $rec['date'] ?: '3000-00-00'; $duedate = !empty($rec['date']) ? $rec['date'] : '3000-00-00';
if ($rec['flagged']) if (!empty($rec['flagged'])) {
$mask |= self::FILTER_MASK_FLAGGED; $mask |= self::FILTER_MASK_FLAGGED;
if ($this->driver->is_complete($rec)) }
if ($this->driver->is_complete($rec)) {
$mask |= self::FILTER_MASK_COMPLETE; $mask |= self::FILTER_MASK_COMPLETE;
}
if (empty($rec['date'])) if (empty($rec['date'])) {
$mask |= self::FILTER_MASK_NODATE; $mask |= self::FILTER_MASK_NODATE;
else if ($rec['date'] < $today) }
else if ($rec['date'] < $today) {
$mask |= self::FILTER_MASK_OVERDUE; $mask |= self::FILTER_MASK_OVERDUE;
}
if (empty($rec['recurrence']) || $duedate < $today || $start > $weeklimit) { if (empty($rec['recurrence']) || $duedate < $today || $start > $weeklimit) {
if ($duedate <= $today || ($rec['startdate'] && $start <= $today)) if ($duedate <= $today || (!empty($rec['startdate']) && $start <= $today))
$mask |= self::FILTER_MASK_TODAY; $mask |= self::FILTER_MASK_TODAY;
else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow)) else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow))
$mask |= self::FILTER_MASK_TOMORROW; $mask |= self::FILTER_MASK_TOMORROW;
@ -1343,8 +1360,8 @@ class tasklist extends rcube_plugin
else if ($start > $weeklimit || $duedate > $weeklimit) else if ($start > $weeklimit || $duedate > $weeklimit)
$mask |= self::FILTER_MASK_LATER; $mask |= self::FILTER_MASK_LATER;
} }
else if ($rec['startdate'] || $rec['date']) { else if (!empty($rec['startdate']) || !empty($rec['date'])) {
$date = new DateTime($rec['startdate'] ?: $rec['date'], $this->timezone); $date = new libcalendaring_datetime(!empty($rec['startdate']) ? $rec['startdate'] : $rec['date'], $this->timezone);
// set safe recurrence start // set safe recurrence start
while ($date->format('Y-m-d') >= $today) { while ($date->format('Y-m-d') >= $today) {
@ -1392,10 +1409,12 @@ class tasklist extends rcube_plugin
} }
// add masks for assigned tasks // add masks for assigned tasks
if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) {
$mask |= self::FILTER_MASK_ASSIGNED; $mask |= self::FILTER_MASK_ASSIGNED;
else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) }
else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) {
$mask |= self::FILTER_MASK_MYTASKS; $mask |= self::FILTER_MASK_MYTASKS;
}
return $mask; return $mask;
} }
@ -1406,7 +1425,7 @@ class tasklist extends rcube_plugin
public function is_attendee($task) public function is_attendee($task)
{ {
$emails = $this->lib->get_user_emails(); $emails = $this->lib->get_user_emails();
foreach ((array)$task['attendees'] as $i => $attendee) { foreach ((array) ($task['attendees'] ?? []) as $i => $attendee) {
if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) {
return $i; return $i;
} }
@ -1732,7 +1751,7 @@ class tasklist extends rcube_plugin
$id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC);
$rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC);
$task = array('id' => $task, 'list' => $list, 'rev' => $rev); $task = ['id' => $task, 'list' => $list, 'rev' => $rev];
$attachment = $this->driver->get_attachment($id, $task); $attachment = $this->driver->get_attachment($id, $task);
// show part page // show part page
@ -1741,7 +1760,9 @@ class tasklist extends rcube_plugin
} }
// deliver attachment content // deliver attachment content
else if ($attachment) { else if ($attachment) {
$attachment['body'] = $this->driver->get_attachment_body($id, $task); if (empty($attachment['body'])) {
$attachment['body'] = $this->driver->get_attachment_body($id, $task);
}
$handler->attachment_get($attachment); $handler->attachment_get($attachment);
} }

View file

@ -21,13 +21,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
#[AllowDynamicProperties]
class tasklist_ui class tasklist_ui
{ {
private $rc; private $rc;
private $plugin; private $plugin;
private $ready = false; private $ready = false;
private $gui_objects = array(); private $gui_objects = [];
function __construct($plugin) function __construct($plugin)
{ {
@ -74,7 +74,7 @@ class tasklist_ui
*/ */
function load_settings() function load_settings()
{ {
$settings = array(); $settings = [];
$settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0); $settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0);
$settings['itip_notify'] = (int)$this->rc->config->get('calendar_itip_send_option', 3); $settings['itip_notify'] = (int)$this->rc->config->get('calendar_itip_send_option', 3);
@ -120,7 +120,7 @@ class tasklist_ui
/** /**
* Render a HTML select box for user identity selection * Render a HTML select box for user identity selection
*/ */
function identity_select($attrib = array()) function identity_select($attrib = [])
{ {
$attrib['name'] = 'identity'; $attrib['name'] = 'identity';
$select = new html_select($attrib); $select = new html_select($attrib);
@ -165,10 +165,10 @@ class tasklist_ui
/** /**
* *
*/ */
public function tasklists($attrib = array()) public function tasklists($attrib = [])
{ {
$tree = true; $tree = true;
$jsenv = array(); $jsenv = [];
$lists = $this->plugin->driver->get_lists(0, $tree); $lists = $this->plugin->driver->get_lists(0, $tree);
if (empty($attrib['id'])) { if (empty($attrib['id'])) {
@ -181,18 +181,18 @@ class tasklist_ui
} }
else { else {
// fall-back to flat folder listing // fall-back to flat folder listing
$attrib['class'] .= ' flat'; $attrib['class'] = ($attrib['class'] ?? '') . ' flat';
$html = ''; $html = '';
foreach ((array)$lists as $id => $prop) {
foreach ((array) $lists as $id => $prop) {
if (!empty($attrib['activeonly']) && empty($prop['active'])) { if (!empty($attrib['activeonly']) && empty($prop['active'])) {
continue; continue;
} }
$html .= html::tag('li', array( $html .= html::tag('li', [
'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id),
'class' => isset($prop['group']) ? $prop['group'] : null, 'class' => $prop['group'] ?? null,
), ],
$this->tasklist_list_item($id, $prop, $jsenv, !empty($attrib['activeonly'])) $this->tasklist_list_item($id, $prop, $jsenv, !empty($attrib['activeonly']))
); );
} }
@ -288,15 +288,19 @@ class tasklist_ui
'aria-labelledby' => $label_id 'aria-labelledby' => $label_id
)); ));
$actions = '';
if (!empty($prop['removable'])) {
$actions .= html::a(['href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')], ' ');
}
$actions .= html::a(['href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'], ' ');
if (isset($prop['subscribed'])) {
$action .= html::a(['href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'], ' ');
}
return html::div(join(' ', $classes), return html::div(join(' ', $classes),
html::a(array('class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id), html::a(['class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id],
!empty($prop['listname']) ? $prop['listname'] : $prop['name']) . !empty($prop['listname']) ? $prop['listname'] : $prop['name'])
(!empty($prop['virtual']) ? '' : $chbox . html::span('actions', . (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', $actions))
(!empty($prop['removable']) ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '')
. html::a(array('href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'), ' ')
. (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '')
)
)
); );
} }