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;
public $screen;
public $action;
public $calendar;
function __construct($cal)
{

View file

@ -33,8 +33,8 @@ use \Sabre\VObject\DateTimeParser;
class libcalendaring_vcalendar implements Iterator
{
private $timezone;
private $attach_uri = null;
private $prodid = '-//Roundcube libcalendaring//Sabre//Sabre VObject//EN';
private $attach_uri;
private $prodid;
private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO');
private $attendee_keymap = array(
'name' => 'CN',
@ -73,7 +73,7 @@ class libcalendaring_vcalendar implements Iterator
function __construct($tz = null)
{
$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`)
) 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;
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) {
$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']))
|| $folder['type'] === $component
|| in_array($component, (array) $folder['types'])
) {
$folders[] = $folder;
}
@ -296,18 +296,7 @@ class kolab_dav_client
$response = $this->request($location, 'PUT', $content, $headers);
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;
return $this->getETagFromResponse($response);
}
/**
@ -338,6 +327,23 @@ class kolab_dav_client
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.
*
@ -665,10 +671,10 @@ class kolab_dav_client
$ctag = $ctag->nodeValue;
}
$component = null;
$components = [];
if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) {
if ($comp_element = $set_element->getElementsByTagName('comp')->item(0)) {
$component = $comp_element->attributes->getNamedItem('name')->nodeValue;
foreach ($set_element->getElementsByTagName('comp') as $comp_element) {
$components[] = $comp_element->attributes->getNamedItem('name')->nodeValue;
}
}
@ -685,7 +691,7 @@ class kolab_dav_client
'name' => $name,
'ctag' => $ctag,
'color' => $color,
'type' => $component,
'types' => $components,
'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
*/

View file

@ -751,7 +751,7 @@ abstract class kolab_format
}
// in kolab_storage attachments are indexed by content-id
foreach ((array) $object['attachments'] as $attachment) {
foreach ((array) ($object['attachments'] ?? []) as $attachment) {
$key = null;
// 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'));
}
if ($storage_folder)
if ($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` = ?";
$write_query = "UPDATE `{$this->folders_table}` SET `synclock` = ? WHERE `folder_id` = ? AND `synclock` = ?";
$max_lock_time = $this->_max_sync_lock_time();
$sync_lock = intval($this->metadata['synclock'] ?? 0);
// wait if locked (expire locks after 10 minutes) ...
// ... or if setting lock fails (another process meanwhile set it)
while (
($sync_lock + $max_lock_time > time()) ||
(($res = $this->db->query($write_query, time(), $this->folder_id, $sync_lock))
(intval($this->metadata['synclock'] ?? 0) + $max_lock_time > time()) ||
(($res = $this->db->query($write_query, time(), $this->folder_id, intval($this->metadata['synclock'] ?? 0)))
&& !($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
if (!empty($new_index)) {
$i = 0;
foreach (array_chunk($new_index, $chunk_size, true) as $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;
}
/**
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#[AllowDynamicProperties]
class kolab_storage_dav_folder extends kolab_storage_folder
{
public $dav;
@ -310,8 +312,8 @@ class kolab_storage_dav_folder extends kolab_storage_folder
/**
* Move a Kolab object message to another IMAP folder
*
* @param string Object UID
* @param string IMAP folder to move object to
* @param string Object UID
* @param kolab_storage_dav_folder Target folder to move object into
*
* @return bool True on success, false on failure
*/
@ -321,9 +323,16 @@ class kolab_storage_dav_folder extends kolab_storage_folder
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;
}
if ($this->type == 'event') {
if ($this->type == 'event' || $this->type == 'task') {
$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;
}
$result = $events[0];
$result = $objects[0];
$result['_attachments'] = $result['attachments'] ?? [];
unset($result['attachments']);
@ -550,12 +559,15 @@ class kolab_storage_dav_folder extends kolab_storage_folder
{
$result = '';
if ($this->type == 'event') {
if ($this->type == 'event' || $this->type == 'task') {
$ical = libcalendaring::get_ical();
if (!empty($object['exceptions'])) {
$object['recurrence']['EXCEPTIONS'] = $object['exceptions'];
}
$object['_type'] = $this->type;
// pre-process attachments
if (isset($object['_attachments']) && is_array($object['_attachments'])) {
foreach ($object['_attachments'] as $key => $attachment) {
@ -669,7 +681,7 @@ class kolab_storage_dav_folder extends kolab_storage_folder
return $result;
}
protected function object_location($uid)
public function object_location($uid)
{
return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext();
}

View file

@ -1,7 +1,7 @@
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.
@ -43,7 +43,7 @@ driver.
$ cd ../../
$ 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

View file

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

View file

@ -99,7 +99,9 @@
<legend><roundcube:label name="tasklist.tabassignments" /></legend>
<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>
<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>
<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"

View file

@ -296,7 +296,7 @@ function rcube_tasklist_ui(settings)
rcmail.addEventListener('plugin.update_tasklist', update_list);
rcmail.addEventListener('plugin.destroy_tasklist', destroy_list);
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('plugin.reload_data', function(){
list_tasks(null, true);
@ -2755,10 +2755,10 @@ function rcube_tasklist_ui(settings)
*/
function task_show_attachments(list, container, task, edit)
{
libkolab.list_attachments(list, container, edit, task,
function(id) { remove_attachment(id); },
function(data) { load_attachment(data); }
);
libkolab.list_attachments(list, container, edit, task,
function(id) { remove_attachment(id); },
function(data) { load_attachment(data); }
);
};
/**

View file

@ -22,6 +22,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#[AllowDynamicProperties]
class tasklist extends rcube_plugin
{
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);
$rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true);
$oldrec = $rec;
$success = $refresh = $got_msg = false;
$success = $got_msg = false;
$refresh = [];
// force notify if hidden + active
$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) {
$r = $rec;
$r['id'] = $id;
if ($this->driver->move_task($r)) {
$new_task = $this->driver->get_task($r);
if ($this->driver->move_task($r) && ($new_task = $this->driver->get_task($r))) {
$new_task['tempid'] = $id;
$refresh[] = $new_task;
$success = true;
@ -330,7 +331,7 @@ class tasklist extends rcube_plugin
// update parent task to adjust list of children
if (!empty($oldrec['parent_id'])) {
$parent = array('id' => $oldrec['parent_id'], 'list' => $rec['list']);
if ($parent = $this->driver->get_task()) {
if ($parent = $this->driver->get_task($parent)) {
$refresh[] = $parent;
}
}
@ -547,23 +548,26 @@ class tasklist extends rcube_plugin
$itip = $this->load_itip();
$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');
else
}
else {
$this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error');
}
}
}
// unlock client
$this->rc->output->command('plugin.unlock_saving', $success);
if ($refresh) {
if (!empty($refresh)) {
if (!empty($refresh['id'])) {
$this->encode_task($refresh);
}
else if (is_array($refresh)) {
foreach ($refresh as $i => $r)
foreach ($refresh as $i => $r) {
$this->encode_task($refresh[$i]);
}
}
$this->rc->output->command('plugin.update_task', $refresh);
}
@ -688,13 +692,13 @@ class tasklist extends rcube_plugin
}
// convert the submitted recurrence settings
if (is_array($rec['recurrence'])) {
if (isset($rec['recurrence']) && is_array($rec['recurrence'])) {
$refdate = null;
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'])) {
$refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone);
$refdate = new DateTime($rec['startdate'] . ' ' . ($rec['starttime'] ?? ''), $this->timezone);
}
if ($refdate) {
@ -732,7 +736,7 @@ class tasklist extends rcube_plugin
if (!empty($rec['attendees'])) {
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';
}
}
@ -768,16 +772,17 @@ class tasklist extends rcube_plugin
try {
// parse date from user format (#2801)
$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
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');
if (!empty($rec[$time_key]))
if (!empty($rec[$time_key])) {
$rec[$time_key] = $date->format('H:i');
}
return true;
}
@ -804,7 +809,7 @@ class tasklist extends rcube_plugin
private function handle_recurrence(&$rec, $old)
{
$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();
$rrule = $rec['recurrence'];
$updates = array();
@ -814,12 +819,13 @@ class tasklist extends rcube_plugin
if (empty($rec[$date_key]))
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);
if ($next = $engine->next_start()) {
$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');
}
}
}
@ -1174,15 +1180,15 @@ class tasklist extends rcube_plugin
*/
private function encode_task(&$rec)
{
$rec['mask'] = $this->filter_mask($rec);
$rec['flagged'] = intval($rec['flagged']);
$rec['complete'] = floatval($rec['complete']);
$rec['mask'] = $this->filter_mask($rec);
$rec['flagged'] = intval($rec['flagged'] ?? 0);
$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'] = $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'] = $rec['changed']->format('U');
}
@ -1190,9 +1196,9 @@ class tasklist extends rcube_plugin
$rec['changed'] = null;
}
if ($rec['date']) {
if (!empty($rec['date'])) {
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['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d'));
$rec['_hasdate'] = 1;
@ -1206,9 +1212,9 @@ class tasklist extends rcube_plugin
$rec['_hasdate'] = 0;
}
if ($rec['startdate']) {
if (!empty($rec['startdate'])) {
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['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'])) {
$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'])) {
foreach ((array) $rec['attachments'] as $k => $attachment) {
$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();
}
if (!is_array($rec['tags']))
$rec['tags'] = (array)$rec['tags'];
if (!isset($rec['tags']) || !is_array($rec['tags'])) {
$rec['tags'] = (array) ($rec['tags'] ?? '');
}
sort($rec['tags'], SORT_LOCALE_STRING);
if (in_array($rec['id'], $this->collapsed_tasks))
$rec['collapsed'] = true;
if (in_array($rec['id'], $this->collapsed_tasks)) {
$rec['collapsed'] = true;
}
if (empty($rec['parent_id']))
if (empty($rec['parent_id'])) {
$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)
{
// 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;
if (!$today) {
$today_date = new DateTime('now', $this->timezone);
$today_date = new libcalendaring_datetime('now', $this->timezone);
$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');
// In Kolab-mode we hide "Next 7 days" filter, which means
@ -1320,21 +1333,25 @@ class tasklist extends rcube_plugin
}
$mask = 0;
$start = $rec['startdate'] ?: '1900-00-00';
$duedate = $rec['date'] ?: '3000-00-00';
$start = !empty($rec['startdate']) ? $rec['startdate'] : '1900-00-00';
$duedate = !empty($rec['date']) ? $rec['date'] : '3000-00-00';
if ($rec['flagged'])
if (!empty($rec['flagged'])) {
$mask |= self::FILTER_MASK_FLAGGED;
if ($this->driver->is_complete($rec))
}
if ($this->driver->is_complete($rec)) {
$mask |= self::FILTER_MASK_COMPLETE;
}
if (empty($rec['date']))
if (empty($rec['date'])) {
$mask |= self::FILTER_MASK_NODATE;
else if ($rec['date'] < $today)
}
else if ($rec['date'] < $today) {
$mask |= self::FILTER_MASK_OVERDUE;
}
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;
else if (($start > $today && $start <= $tomorrow) || ($duedate > $today && $duedate <= $tomorrow))
$mask |= self::FILTER_MASK_TOMORROW;
@ -1343,8 +1360,8 @@ class tasklist extends rcube_plugin
else if ($start > $weeklimit || $duedate > $weeklimit)
$mask |= self::FILTER_MASK_LATER;
}
else if ($rec['startdate'] || $rec['date']) {
$date = new DateTime($rec['startdate'] ?: $rec['date'], $this->timezone);
else if (!empty($rec['startdate']) || !empty($rec['date'])) {
$date = new libcalendaring_datetime(!empty($rec['startdate']) ? $rec['startdate'] : $rec['date'], $this->timezone);
// set safe recurrence start
while ($date->format('Y-m-d') >= $today) {
@ -1392,10 +1409,12 @@ class tasklist extends rcube_plugin
}
// 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;
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;
}
return $mask;
}
@ -1406,7 +1425,7 @@ class tasklist extends rcube_plugin
public function is_attendee($task)
{
$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)) {
return $i;
}
@ -1732,7 +1751,7 @@ class tasklist extends rcube_plugin
$id = rcube_utils::get_input_value('_id', 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);
// show part page
@ -1741,7 +1760,9 @@ class tasklist extends rcube_plugin
}
// deliver attachment content
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);
}

View file

@ -21,13 +21,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#[AllowDynamicProperties]
class tasklist_ui
{
private $rc;
private $plugin;
private $ready = false;
private $gui_objects = array();
private $gui_objects = [];
function __construct($plugin)
{
@ -74,7 +74,7 @@ class tasklist_ui
*/
function load_settings()
{
$settings = array();
$settings = [];
$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);
@ -120,7 +120,7 @@ class tasklist_ui
/**
* Render a HTML select box for user identity selection
*/
function identity_select($attrib = array())
function identity_select($attrib = [])
{
$attrib['name'] = 'identity';
$select = new html_select($attrib);
@ -165,10 +165,10 @@ class tasklist_ui
/**
*
*/
public function tasklists($attrib = array())
public function tasklists($attrib = [])
{
$tree = true;
$jsenv = array();
$tree = true;
$jsenv = [];
$lists = $this->plugin->driver->get_lists(0, $tree);
if (empty($attrib['id'])) {
@ -181,18 +181,18 @@ class tasklist_ui
}
else {
// fall-back to flat folder listing
$attrib['class'] .= ' flat';
$attrib['class'] = ($attrib['class'] ?? '') . ' flat';
$html = '';
foreach ((array)$lists as $id => $prop) {
foreach ((array) $lists as $id => $prop) {
if (!empty($attrib['activeonly']) && empty($prop['active'])) {
continue;
}
$html .= html::tag('li', array(
$html .= html::tag('li', [
'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']))
);
}
@ -288,15 +288,19 @@ class tasklist_ui
'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),
html::a(array('class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id),
!empty($prop['listname']) ? $prop['listname'] : $prop['name']) .
(!empty($prop['virtual']) ? '' : $chbox . html::span('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'), ' ') : '')
)
)
html::a(['class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id],
!empty($prop['listname']) ? $prop['listname'] : $prop['name'])
. (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', $actions))
);
}