From 21ead0149e8597c44365f9efed01e24dec6b1591 Mon Sep 17 00:00:00 2001 From: Aleksander Machniak Date: Tue, 24 Dec 2024 09:00:30 +0100 Subject: [PATCH] Kolab Tags SQL and ANNOTATE drivers Summary: The implementation in the SQL driver sub-optimal as it uses the same member format and internal API as the Kolab driver. We might need to improve this. Requires https://github.com/roundcube/roundcubemail/commit/b206cbc87a2f766dfc8150a4fdf29531e0782e27 and https://github.com/roundcube/roundcubemail/commit/a34f7160511e87bff64254fbafb7d10de8e0f59d Reviewers: #roundcube_kolab_plugins_developers, mollekopf Subscribers: mollekopf, #roundcube_kolab_plugins_developers Differential Revision: https://git.kolab.org/D5032 --- plugins/kolab_tags/README | 71 +++ plugins/kolab_tags/composer.json | 2 +- plugins/kolab_tags/config.inc.php.dist | 4 + .../kolab_tags/drivers/DriverInterface.php | 102 ++++ .../kolab_tags/drivers/annotate/Driver.php | 196 ++++++++ .../kolab_tags/drivers/database/Driver.php | 394 +++++++++++++++ .../drivers/database/SQL/mysql.initial.sql | 27 + plugins/kolab_tags/drivers/kolab/Driver.php | 268 ++++++++++ plugins/kolab_tags/kolab_tags.js | 2 +- plugins/kolab_tags/kolab_tags.php | 14 +- plugins/kolab_tags/lib/kolab_tags_backend.php | 110 ---- plugins/kolab_tags/lib/kolab_tags_engine.php | 255 +++------- .../skins/elastic/templates/ui.html | 4 +- .../libcalendaring/tests/VcalendarTest.php | 1 + .../libcalendaring/tests/resources/itip.ics | 1 + plugins/libkolab/lib/kolab_storage_cache.php | 22 +- .../lib/kolab_storage_cache_configuration.php | 4 +- plugins/libkolab/lib/kolab_storage_config.php | 6 +- .../libkolab/lib/kolab_storage_dav_cache.php | 4 +- plugins/libkolab/lib/kolab_storage_tags.php | 474 ++++++++++++++++++ .../skins/elastic/include/kolab_tags.less | 1 - .../drivers/caldav/tasklist_caldav_driver.php | 32 +- 22 files changed, 1636 insertions(+), 358 deletions(-) create mode 100644 plugins/kolab_tags/README create mode 100644 plugins/kolab_tags/config.inc.php.dist create mode 100644 plugins/kolab_tags/drivers/DriverInterface.php create mode 100644 plugins/kolab_tags/drivers/annotate/Driver.php create mode 100644 plugins/kolab_tags/drivers/database/Driver.php create mode 100644 plugins/kolab_tags/drivers/database/SQL/mysql.initial.sql create mode 100644 plugins/kolab_tags/drivers/kolab/Driver.php delete mode 100644 plugins/kolab_tags/lib/kolab_tags_backend.php create mode 100644 plugins/libkolab/lib/kolab_storage_tags.php diff --git a/plugins/kolab_tags/README b/plugins/kolab_tags/README new file mode 100644 index 00000000..fb19687f --- /dev/null +++ b/plugins/kolab_tags/README @@ -0,0 +1,71 @@ +A mail tags module for Roundcube +-------------------------------------- + +This plugin currently supports a few storage backends: +- "database": SQL database +- "kolab": Kolab Groupware v3 Server +- "annotate": IMAP server with METADATA and ANNOTATE-EXPERIMENT-1 support (e.g. Cyrus IMAP) + + +REQUIREMENTS +------------ + +Some functions are shared with other plugins and therefore being moved to +library plugins. Thus in order to run the kolab_tags plugin, you also need the +following plugins installed: + +* kolab/libkolab [1] + + +INSTALLATION +------------ + +For a manual installation of the plugin (and its dependencies), +execute the following steps. This will set it up with the database backend +driver. + +1. Get the source from git + + $ cd /tmp + $ git clone https://git.kolab.org/diffusion/RPK/roundcubemail-plugins-kolab.git + $ cd //plugins + $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/kolab_tags . + $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/libkolab . + +2. Create kolab_tags plugin configuration + + $ cd kolab_tags/ + $ cp config.inc.php.dist config.inc.php + $ edit config.inc.php + +3. Initialize the plugin database tables + + $ cd ../../ + $ bin/initdb.sh --dir=plugins/kolab_tags/drivers/database/SQL + +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 + +5. Enable the plugin + + $ edit config/config.inc.php + +Add 'kolab_tags' to the list of active plugins: + + $config['plugins'] = [ + (...) + 'kolab_tags', + ]; + + +IMPORTANT +--------- + +This plugin doesn't work with the Classic skin of Roundcube because no +templates are available for that skin. + +Use Roundcube `skins_allowed` option to limit skins available to the user +or remove incompatible skins from the skins folder. + +[1] https://git.kolab.org/diffusion/RPK/ diff --git a/plugins/kolab_tags/composer.json b/plugins/kolab_tags/composer.json index f481ce51..35e84558 100644 --- a/plugins/kolab_tags/composer.json +++ b/plugins/kolab_tags/composer.json @@ -4,7 +4,7 @@ "description": "Email tags plugin", "homepage": "https://git.kolab.org/diffusion/RPK/", "license": "AGPLv3", - "version": "3.5.2", + "version": "3.5.3", "authors": [ { "name": "Aleksander Machniak", diff --git a/plugins/kolab_tags/config.inc.php.dist b/plugins/kolab_tags/config.inc.php.dist new file mode 100644 index 00000000..acdab06f --- /dev/null +++ b/plugins/kolab_tags/config.inc.php.dist @@ -0,0 +1,4 @@ + + * + * Copyright (C) 2024, Apheleia IT AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace KolabTags\Drivers; + +interface DriverInterface +{ + /** + * Tags list + * + * @param array $filter Search filter + * + * @return array List of tags + */ + public function list_tags($filter = []); + + /** + * Create tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function create($tag); + + /** + * Update tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function update($tag); + + /** + * Remove tag object + * + * @param string $uid Object unique identifier + * + * @return bool True on success, False on failure + */ + public function remove($uid); + + /** + * Build IMAP SEARCH criteria for mail messages search (per-folder) + * + * @param array $tag Tag data + * @param array $folders List of folders to search in + * + * @return array IMAP SEARCH criteria per-folder + */ + public function members_search_criteria($tag, $folders); + + /** + * Returns tag assignments with multiple members + * + * @param array<\rcube_message_header> $messages Mail messages + * + * @return array Tags assigned + */ + public function members_tags($messages); + + /** + * Add mail members to a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function add_tag_members($tag, $messages); + + /** + * Remove mail members from a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function remove_tag_members($tag, $messages); +} diff --git a/plugins/kolab_tags/drivers/annotate/Driver.php b/plugins/kolab_tags/drivers/annotate/Driver.php new file mode 100644 index 00000000..1956ed1e --- /dev/null +++ b/plugins/kolab_tags/drivers/annotate/Driver.php @@ -0,0 +1,196 @@ + + * + * Copyright (C) 2024, Apheleia IT AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace KolabTags\Drivers\Annotate; + +use KolabTags\Drivers\DriverInterface; +use kolab_storage_tags; +use rcube; +use rcube_imap_generic; +use rcube_message_header; + +class Driver implements DriverInterface +{ + public $immutableName = true; + + protected $engine; + + /** + * Class constructor + */ + public function __construct() + { + $this->engine = new kolab_storage_tags(); + } + + /** + * Tags list + * + * @param array $filter Search filter + * + * @return array List of tags + */ + public function list_tags($filter = []) + { + return $this->engine->list($filter); + } + + /** + * Create tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function create($tag) + { + $tag['uid'] = $this->engine->create($tag); + + if (empty($tag['uid'])) { + return false; + } + + return $tag; + } + + /** + * Update tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function update($tag) + { + $success = $this->engine->update($tag); + + return $success ? $tag : false; + } + + /** + * Remove tag object + * + * @param string $uid Object unique identifier + * + * @return bool True on success, False on failure + */ + public function remove($uid) + { + return $this->engine->delete($uid); + } + + /** + * Build IMAP SEARCH criteria for mail messages search (per-folder) + * + * @param array $tag Tag data + * @param array $folders List of folders to search in + * + * @return array IMAP SEARCH criteria per-folder + */ + public function members_search_criteria($tag, $folders) + { + $result = []; + foreach ($folders as $folder) { + $result[$folder] = $this->engine->imap_search_criteria($tag['name']); + } + + return $result; + } + + /** + * Returns tag assignments with multiple members + * + * @param array $messages Mail messages + * + * @return array Tags assigned + */ + public function members_tags($messages) + { + return $this->engine->members_tags($messages); + } + + /** + * Add mail members to a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function add_tag_members($tag, $messages) + { + $storage = rcube::get_instance()->get_storage(); + $members = []; + + foreach ($messages as $mbox => $uids) { + if ($uids === '*') { + $index = $storage->index($mbox, null, null, true); + $uids = $index->get(); + } + + if (empty($uids)) { + continue; + } + + $result = $this->engine->add_members($tag['uid'], $mbox, $uids); + + if (!$result) { + return false; + } + } + + return true; + } + + /** + * Remove mail members from a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function remove_tag_members($tag, $messages) + { + $storage = rcube::get_instance()->get_storage(); + + foreach ($messages as $mbox => $uids) { + if ($uids === '*') { + $index = $storage->index($mbox, null, null, true); + $uids = $index->get(); + } + + if (empty($uids)) { + continue; + } + + $result = $this->engine->remove_members($tag['uid'], $mbox, $uids); + + if (!$result) { + return false; + } + } + + return true; + } +} diff --git a/plugins/kolab_tags/drivers/database/Driver.php b/plugins/kolab_tags/drivers/database/Driver.php new file mode 100644 index 00000000..969a35c6 --- /dev/null +++ b/plugins/kolab_tags/drivers/database/Driver.php @@ -0,0 +1,394 @@ + + * + * Copyright (C) 2024, Apheleia IT AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace KolabTags\Drivers\Database; + +use KolabTags\Drivers\DriverInterface; +use kolab_storage_cache; +use kolab_storage_config; +use rcube; +use rcube_imap_generic; +use rcube_message_header; + +class Driver implements DriverInterface +{ + public $immutableName = false; + + private $members_table = 'kolab_tag_members'; + private $tags_table = 'kolab_tags'; + private $tag_cols = ['name', 'color']; + + + /** + * Tags list + * + * @param array $filter Search filter + * + * @return array List of tags + */ + public function list_tags($filter = []) + { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $user_id = $rcube->get_user_id(); + + // Parse filter, convert 'uid' into 'id' + foreach ($filter as $idx => $record) { + if ($record[0] == 'uid') { + $filter[$idx][0] = 'id'; + } + } + + $where = kolab_storage_cache::sql_where($filter); + + $query = $db->query("SELECT * FROM `{$this->tags_table}` WHERE `user_id` = {$user_id}" . $where); + + $result = []; + while ($tag = $db->fetch_assoc($query)) { + $tag['uid'] = $tag['id']; // the API expects 'uid' property + unset($tag['id']); + $result[] = $tag; + } + + return $result; + } + + /** + * Create tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function create($tag) + { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $user_id = $rcube->get_user_id(); + $insert = []; + + foreach ($this->tag_cols as $col) { + if (isset($tag[$col])) { + $insert[$db->quoteIdentifier($col)] = $db->quote($tag[$col]); + } + } + + if (empty($insert)) { + return false; + } + + $now = new \DateTime('now', new \DateTimeZone('UTC')); + + $insert['user_id'] = $user_id; + $insert['created'] = $insert['updated'] = $now->format("'Y-m-d H:i:s'"); + + $result = $db->query( + "INSERT INTO `{$this->tags_table}`" + . " (" . implode(', ', array_keys($insert)) . ")" + . " VALUES(" . implode(', ', array_values($insert)) . ")" + ); + + $tag['uid'] = $db->insert_id($this->tags_table); + + if (empty($tag['uid'])) { + return false; + } + + return $tag; + } + + /** + * Update tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function update($tag) + { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $user_id = $rcube->get_user_id(); + $update = []; + + foreach ($this->tag_cols as $col) { + if (isset($tag[$col])) { + $update[] = $db->quoteIdentifier($col) . ' = ' . $db->quote($tag[$col]); + } + } + + if (!empty($update)) { + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $update[] = '`updated` = ' . $db->quote($now->format('Y-m-d H:i:s')); + if (isset($tag['name'])) { + $update[] = '`modseq` = `modseq` + (CASE WHEN name <> ' . $db->quote($tag['name']) . ' THEN 1 ELSE 0 END)'; + } + + $result = $db->query("UPDATE `{$this->tags_table}` SET " . implode(', ', $update) + . " WHERE `id` = ? AND `user_id` = ?", $tag['uid'], $user_id); + + if ($result === false) { + return false; + } + } + + return $tag; + } + + /** + * Remove tag object + * + * @param string $uid Object unique identifier + * + * @return bool True on success, False on failure + */ + public function remove($uid) + { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $user_id = $rcube->get_user_id(); + + $result = $db->query("DELETE FROM `{$this->tags_table}` WHERE `id` = ? AND `user_id` = ?", $uid, $user_id); + + return $db->affected_rows($result) > 0; + } + + /** + * Build IMAP SEARCH criteria for mail messages search (per-folder) + * + * @param array $tag Tag data + * @param array $folders List of folders to search in + * + * @return array IMAP SEARCH criteria per-folder + */ + public function members_search_criteria($tag, $folders) + { + $tag_members = self::resolve_members($tag); + $result = []; + + foreach ($tag_members as $folder => $uid_list) { + if (!empty($uid_list) && in_array($folder, $folders)) { + $result[$folder] = 'UID ' . rcube_imap_generic::compressMessageSet($uid_list); + } + } + + return $result; + } + + /** + * Returns tag assignments with multiple members + * + * @param array $messages Mail messages + * + * @return array Tags assigned + */ + public function members_tags($messages) + { + // get tags list + $taglist = $this->list_tags(); + + // get message UIDs + $message_tags = []; + foreach ($messages as $msg) { + $message_tags[$msg->uid . '-' . $msg->folder] = null; + } + + $uids = array_keys($message_tags); + + foreach ($taglist as $tag) { + $tag_members = self::resolve_members($tag); + + foreach ((array) $tag_members as $folder => $_uids) { + array_walk($_uids, function (&$uid, $key, $folder) { $uid .= '-' . $folder; }, $folder); + + foreach (array_intersect($uids, $_uids) as $uid) { + $message_tags[$uid][] = $tag['uid']; + } + } + } + + return array_filter($message_tags); + } + + /** + * Add mail members to a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function add_tag_members($tag, $messages) + { + $rcube = rcube::get_instance(); + $storage = $rcube->get_storage(); + $members = []; + + // build list of members + foreach ($messages as $mbox => $uids) { + if ($uids === '*') { + $index = $storage->index($mbox, null, null, true); + $uids = $index->get(); + $msgs = $storage->fetch_headers($mbox, $uids, false); + } else { + $msgs = $storage->fetch_headers($mbox, $uids, false); + } + + // fetch_headers doesn't detect IMAP errors, so we make sure we get something back. + if (!empty($uids) && empty($msgs)) { + throw new \Exception("Failed to find relation members, check the IMAP log."); + } + + $members = array_merge($members, kolab_storage_config::build_members($mbox, $msgs)); + } + + if (empty($members)) { + return false; + } + + $db = $rcube->get_dbh(); + $existing = []; + + $query = $db->query("SELECT `url` FROM `{$this->members_table}` WHERE `tag_id` = ?", $tag['uid']); + + while ($member = $db->fetch_assoc($query)) { + $existing[] = $member['url']; + } + + $insert = array_unique(array_merge((array) $existing, $members)); + + if (!empty($insert)) { + $ts = (new \DateTime('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + + foreach (array_chunk($insert, 100) as $chunk) { + $query = "INSERT INTO `{$this->members_table}` (`tag_id`, `url`, `created`) VALUES "; + foreach ($chunk as $idx => $url) { + $chunk[$idx] = sprintf("(%d, %s, %s)", $tag['uid'], $db->quote($url), $db->quote($ts)); + } + + $query = $db->query($query . implode(', ', $chunk)); + } + } + + return true; + } + + /** + * Remove mail members from a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function remove_tag_members($tag, $messages) + { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + + // TODO: Why we use URLs? It's because the original Kolab driver does this. + // We should probably store folder, uid, and Message-ID separately (and just ignore other headers). + // As it currently is the queries below seem to be fragile. + + foreach ($messages as $mbox => $uids) { + if ($uids === '*') { + $folder_url = kolab_storage_config::build_member_url(['folder' => $mbox]); + $regexp = '^' . $folder_url . '/[0-9]+\?'; + $query = "DELETE FROM `{$this->members_table}` WHERE `tag_id` = ? AND `url` REGEXP ?"; + if ($db->query($query, $tag['uid'], $regexp) === false) { + return false; + } + } else { + $members = []; + foreach ((array)$uids as $uid) { + $members[] = kolab_storage_config::build_member_url([ + 'folder' => $mbox, + 'uid' => $uid, + ]); + } + + foreach (array_chunk($members, 100) as $chunk) { + foreach ($chunk as $idx => $member) { + $chunk[$idx] = 'LEFT(`url`, ' . (strlen($member) + 1) . ') = ' . $db->quote($member . '?'); + } + + $query = "DELETE FROM `{$this->members_table}` WHERE `tag_id` = ? AND (" . implode(' OR ', $chunk) . ")"; + if ($db->query($query, $tag['uid']) === false) { + return false; + + } + } + } + } + + return true; + } + + /** + * Resolve tag members into (up-to-date) IMAP folder => uids map + */ + protected function resolve_members($tag) + { + $rcube = rcube::get_instance(); + $db = $rcube->get_dbh(); + $existing = []; + + $query = $db->query("SELECT `url` FROM `{$this->members_table}` WHERE `tag_id` = ?", $tag['uid']); + + while ($member = $db->fetch_assoc($query)) { + $existing[] = $member['url']; + } + + $tag['members'] = $existing; + + // TODO: Don't force-resolve members all the time (2nd argument), store the last resolution timestamp + // in kolab_tags table and use some interval (15 minutes?) to not do this heavy operation too often. + $members = kolab_storage_config::resolve_members($tag, true, false); + + // Refresh the members in database + $delete = array_unique(array_diff($existing, $tag['members'])); + $insert = array_unique(array_diff($tag['members'], $existing)); + + if (!empty($delete)) { + foreach (array_chunk($delete, 100) as $chunk) { + $query = "DELETE FROM `{$this->members_table}` WHERE `tag_id` = ? AND `url` IN (" . $db->array2list($chunk) . ")"; + $query = $db->query($query, $tag['uid']); + } + } + + if (!empty($insert)) { + $ts = (new \DateTime('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + + foreach (array_chunk($insert, 100) as $chunk) { + $query = "INSERT INTO `{$this->members_table}` (`tag_id`, `url`, `created`) VALUES "; + foreach ($chunk as $idx => $url) { + $chunk[$idx] = sprintf("(%d, %s, %s)", $tag['uid'], $db->quote($url), $db->quote($ts)); + } + + $query = $db->query($query . implode(', ', $chunk)); + } + } + + return $members; + } +} diff --git a/plugins/kolab_tags/drivers/database/SQL/mysql.initial.sql b/plugins/kolab_tags/drivers/database/SQL/mysql.initial.sql new file mode 100644 index 00000000..797aceb0 --- /dev/null +++ b/plugins/kolab_tags/drivers/database/SQL/mysql.initial.sql @@ -0,0 +1,27 @@ + +CREATE TABLE IF NOT EXISTS `kolab_tags` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` int(10) UNSIGNED NOT NULL, + `name` varchar(255) NOT NULL, + `color` varchar(8) DEFAULT NULL, + `created` datetime DEFAULT NULL, + `updated` datetime DEFAULT NULL, + `modseq` int UNSIGNED DEFAULT 0, + PRIMARY KEY(`id`), + UNIQUE KEY `user_id_name_idx` (`user_id`, `name`), + INDEX (`updated`), + CONSTRAINT `fk_kolab_tags_user_id` FOREIGN KEY (`user_id`) + REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `kolab_tag_members` ( + `tag_id` int UNSIGNED NOT NULL, + `url` varchar(2048) BINARY NOT NULL, + `created` datetime DEFAULT NULL, + PRIMARY KEY(`tag_id`, `url`), + INDEX (`created`), + CONSTRAINT `fk_kolab_tag_members_tag_id` FOREIGN KEY (`tag_id`) + REFERENCES `kolab_tags`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET ascii; + +REPLACE INTO `system` (`name`, `value`) VALUES ('kolab-tags-database-version', '2024112000'); diff --git a/plugins/kolab_tags/drivers/kolab/Driver.php b/plugins/kolab_tags/drivers/kolab/Driver.php new file mode 100644 index 00000000..d356df0e --- /dev/null +++ b/plugins/kolab_tags/drivers/kolab/Driver.php @@ -0,0 +1,268 @@ + + * + * Copyright (C) 2024, Apheleia IT AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace KolabTags\Drivers\Kolab; + +use KolabTags\Drivers\DriverInterface; +use kolab_storage_config; +use rcube_imap_generic; +use rcube_message_header; + +class Driver implements DriverInterface +{ + public const O_TYPE = 'relation'; + public const O_CATEGORY = 'tag'; + + public $immutableName = false; + + private $tag_cols = ['name', 'category', 'color', 'parent', 'iconName', 'priority', 'members']; + + /** + * Tags list + * + * @param array $filter Search filter + * + * @return array List of tags + */ + public function list_tags($filter = []) + { + $config = kolab_storage_config::get_instance(); + $default = true; + $filter[] = ['type', '=', self::O_TYPE]; + $filter[] = ['category', '=', self::O_CATEGORY]; + + // for performance reasons assume there will be no more than 100 tags (per-folder) + + return $config->get_objects($filter, $default, 100); + } + + /** + * Create tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function create($tag) + { + $config = kolab_storage_config::get_instance(); + $tag = array_intersect_key($tag, array_combine($this->tag_cols, $this->tag_cols)); + $tag['category'] = self::O_CATEGORY; + + // Create the object + $result = $config->save($tag, self::O_TYPE); + + return $result ? $tag : false; + } + + /** + * Update tag object + * + * @param array $tag Tag data + * + * @return false|array Tag data on success, False on failure + */ + public function update($tag) + { + // get tag object data, we need _mailbox + $list = $this->list_tags([['uid', '=', $tag['uid']]]); + $old_tag = $list[0] ?? null; + + if (!$old_tag) { + return false; + } + + $config = kolab_storage_config::get_instance(); + $tag = array_intersect_key($tag, array_combine($this->tag_cols, $this->tag_cols)); + $tag = array_merge($old_tag, $tag); + + // Update the object + $result = $config->save($tag, self::O_TYPE); + + return $result ? $tag : false; + } + + /** + * Remove tag object + * + * @param string $uid Object unique identifier + * + * @return bool True on success, False on failure + */ + public function remove($uid) + { + $config = kolab_storage_config::get_instance(); + + return $config->delete($uid); + } + + /** + * Build IMAP SEARCH criteria for mail messages search (per-folder) + * + * @param array $tag Tag data + * @param array $folders List of folders to search in + * + * @return array IMAP SEARCH criteria per-folder + */ + public function members_search_criteria($tag, $folders) + { + $uids = kolab_storage_config::resolve_members($tag, true); + $result = []; + + foreach ($uids as $folder => $uid_list) { + if (!empty($uid_list) && in_array($folder, $folders)) { + $result[$folder] = 'UID ' . rcube_imap_generic::compressMessageSet($uid_list); + } + } + + return $result; + } + + /** + * Returns tag assignments with multiple members + * + * @param array $messages Mail messages + * + * @return array Tags assigned + */ + public function members_tags($messages) + { + // get tags list + $taglist = $this->list_tags(); + + // get message UIDs + $message_tags = []; + foreach ($messages as $msg) { + $message_tags[$msg->uid . '-' . $msg->folder] = null; + } + + $uids = array_keys($message_tags); + + foreach ($taglist as $tag) { + $tag['uids'] = kolab_storage_config::resolve_members($tag, true); + + foreach ((array) $tag['uids'] as $folder => $_uids) { + array_walk($_uids, function (&$uid, $key, $folder) { $uid .= '-' . $folder; }, $folder); + + foreach (array_intersect($uids, $_uids) as $uid) { + $message_tags[$uid][] = $tag['uid']; + } + } + } + + return array_filter($message_tags); + } + + + /** + * Add mail members to a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function add_tag_members($tag, $messages) + { + $storage = \rcube::get_instance()->get_storage(); + $members = []; + + // build list of members + foreach ($messages as $mbox => $uids) { + if ($uids === '*') { + $index = $storage->index($mbox, null, null, true); + $uids = $index->get(); + $msgs = $storage->fetch_headers($mbox, $uids, false); + } else { + $msgs = $storage->fetch_headers($mbox, $uids, false); + } + + // fetch_headers doesn't detect IMAP errors, so we make sure we get something back. + if (!empty($uids) && empty($msgs)) { + throw new \Exception("Failed to find relation members, check the IMAP log."); + } + + $members = array_merge($members, kolab_storage_config::build_members($mbox, $msgs)); + } + + $tag['members'] = array_unique(array_merge((array) ($tag['members'] ?? []), $members)); + + // update tag object + return $this->update($tag); + } + + /** + * Remove mail members from a tag + * + * @param array $tag Tag object + * @param array $messages List of messages in rcmail::get_uids() output format + * + * @return bool True on success, False on error + */ + public function remove_tag_members($tag, $messages) + { + $filter = []; + + foreach ($messages as $mbox => $uids) { + if ($uids === '*') { + $filter[$mbox] = kolab_storage_config::build_member_url(['folder' => $mbox]); + } else { + foreach ((array)$uids as $uid) { + $filter[$mbox][] = kolab_storage_config::build_member_url([ + 'folder' => $mbox, + 'uid' => $uid, + ]); + } + } + } + + $updated = false; + + // @todo: make sure members list is up-to-date (UIDs are up-to-date) + + // ...filter members by folder/uid prefix + foreach ((array) $tag['members'] as $idx => $member) { + foreach ($filter as $members) { + // list of prefixes + if (is_array($members)) { + foreach ($members as $message) { + if ($member == $message || strpos($member, $message . '?') === 0) { + unset($tag['members'][$idx]); + $updated = true; + } + } + } + // one prefix (all messages in a folder) + else { + if (preg_match('/^' . preg_quote($members, '/') . '\/[0-9]+(\?|$)/', $member)) { + unset($tag['members'][$idx]); + $updated = true; + } + } + } + } + + // update tag object + return $updated ? $this->update($tag) : true; + } +} diff --git a/plugins/kolab_tags/kolab_tags.js b/plugins/kolab_tags/kolab_tags.js index 487c7100..e46bbc5b 100644 --- a/plugins/kolab_tags/kolab_tags.js +++ b/plugins/kolab_tags/kolab_tags.js @@ -393,7 +393,7 @@ function tag_form_dialog(id) content.append(row1.append(name_label).append($('
').append(name_input))) .append(row2.append(color_label).append($('
').append(color_input))) .show(); - name_input.focus(); + name_input.prop('disabled', tag && tag.immutableName).focus(); color_input.minicolors(rcmail.env.minicolors_config || {}); } diff --git a/plugins/kolab_tags/kolab_tags.php b/plugins/kolab_tags/kolab_tags.php index c249d1b4..42840434 100644 --- a/plugins/kolab_tags/kolab_tags.php +++ b/plugins/kolab_tags/kolab_tags.php @@ -57,12 +57,12 @@ class kolab_tags extends rcube_plugin private function engine() { if ($this->engine === null) { - // the files module can be enabled/disabled by the kolab_auth plugin + // the plugin can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('kolab_tags_disabled') || !$this->rc->config->get('kolab_tags_enabled', true)) { return $this->engine = false; } - // $this->load_config(); + $this->load_config(); require_once $this->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_tags_engine.php'; @@ -77,13 +77,11 @@ class kolab_tags extends rcube_plugin */ public function startup($args) { - // call this from startup to give a chance to set - // kolab_files_enabled/disabled in kolab_auth plugin - if ($this->rc->output->type != 'html') { - return; - } - if ($engine = $this->engine()) { + if ($this->rc->output->type != 'html') { + return; + } + $engine->ui(); } } diff --git a/plugins/kolab_tags/lib/kolab_tags_backend.php b/plugins/kolab_tags/lib/kolab_tags_backend.php deleted file mode 100644 index 8fd1b352..00000000 --- a/plugins/kolab_tags/lib/kolab_tags_backend.php +++ /dev/null @@ -1,110 +0,0 @@ - - * - * Copyright (C) 2014, Kolab Systems AG - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -class kolab_tags_backend -{ - private $tag_cols = ['name', 'category', 'color', 'parent', 'iconName', 'priority', 'members']; - - public const O_TYPE = 'relation'; - public const O_CATEGORY = 'tag'; - - - /** - * Tags list - * - * @param array $filter Search filter - * - * @return array List of tags - */ - public function list_tags($filter = []) - { - $config = kolab_storage_config::get_instance(); - $default = true; - $filter[] = ['type', '=', self::O_TYPE]; - $filter[] = ['category', '=', self::O_CATEGORY]; - - // for performance reasons assume there will be no more than 100 tags (per-folder) - - return $config->get_objects($filter, $default, 100); - } - - /** - * Create tag object - * - * @param array $tag Tag data - * - * @return boolean|array Tag data on success, False on failure - */ - public function create($tag) - { - $config = kolab_storage_config::get_instance(); - $tag = array_intersect_key($tag, array_combine($this->tag_cols, $this->tag_cols)); - $tag['category'] = self::O_CATEGORY; - - // Create the object - $result = $config->save($tag, self::O_TYPE); - - return $result ? $tag : false; - } - - /** - * Update tag object - * - * @param array $tag Tag data - * - * @return boolean|array Tag data on success, False on failure - */ - public function update($tag) - { - // get tag object data, we need _mailbox - $list = $this->list_tags([['uid', '=', $tag['uid']]]); - $old_tag = $list[0]; - - if (!$old_tag) { - return false; - } - - $config = kolab_storage_config::get_instance(); - $tag = array_intersect_key($tag, array_combine($this->tag_cols, $this->tag_cols)); - $tag = array_merge($old_tag, $tag); - - // Update the object - $result = $config->save($tag, self::O_TYPE); - - return $result ? $tag : false; - } - - /** - * Remove tag object - * - * @param string $uid Object unique identifier - * - * @return boolean True on success, False on failure - */ - public function remove($uid) - { - $config = kolab_storage_config::get_instance(); - - return $config->delete($uid); - } -} diff --git a/plugins/kolab_tags/lib/kolab_tags_engine.php b/plugins/kolab_tags/lib/kolab_tags_engine.php index bf4c485d..971fce62 100644 --- a/plugins/kolab_tags/lib/kolab_tags_engine.php +++ b/plugins/kolab_tags/lib/kolab_tags_engine.php @@ -26,7 +26,6 @@ class kolab_tags_engine private $backend; private $plugin; private $rc; - private $taglist; /** * Class constructor @@ -35,11 +34,16 @@ class kolab_tags_engine { $plugin->require_plugin('libkolab'); - require_once $plugin->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_tags_backend.php'; - - $this->backend = new kolab_tags_backend(); $this->plugin = $plugin; $this->rc = $plugin->rc; + + $driver = $this->rc->config->get('kolab_tags_driver') ?: 'database'; + $class = "\\KolabTags\\Drivers\\" . ucfirst($driver) . "\\Driver"; + + require_once "{$plugin->home}/drivers/DriverInterface.php"; + require_once "{$plugin->home}/drivers/{$driver}/Driver.php"; + + $this->backend = new $class(); } /** @@ -145,7 +149,6 @@ class kolab_tags_engine $this->rc->output->command('plugin.kolab_tags', $response); } - $this->rc->output->send(); } @@ -154,65 +157,24 @@ class kolab_tags_engine */ public function action_remove() { - $tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST); - $filter = $tag == '*' ? [] : [['uid', '=', explode(',', $tag)]]; + $tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST); + $filter = $tag == '*' ? [] : [['uid', '=', explode(',', $tag)]]; $taglist = $this->backend->list_tags($filter); - $filter = []; - $tags = []; - - foreach (rcmail::get_uids() as $mbox => $uids) { - if ($uids === '*') { - $filter[$mbox] = $this->build_member_url(['folder' => $mbox]); - } else { - foreach ((array)$uids as $uid) { - $filter[$mbox][] = $this->build_member_url([ - 'folder' => $mbox, - 'uid' => $uid, - ]); - } - } - } + $tags = []; // for every tag... foreach ($taglist as $tag) { - $updated = false; + $error = !$this->backend->remove_tag_members($tag, rcmail::get_uids()); - // @todo: make sure members list is up-to-date (UIDs are up-to-date) - - // ...filter members by folder/uid prefix - foreach ((array) $tag['members'] as $idx => $member) { - foreach ($filter as $members) { - // list of prefixes - if (is_array($members)) { - foreach ($members as $message) { - if ($member == $message || strpos($member, $message . '?') === 0) { - unset($tag['members'][$idx]); - $updated = true; - } - } - } - // one prefix (all messages in a folder) - else { - if (preg_match('/^' . preg_quote($members, '/') . '\/[0-9]+(\?|$)/', $member)) { - unset($tag['members'][$idx]); - $updated = true; - } - } - } - } - - // update tag object - if ($updated) { - if (!$this->backend->update($tag)) { - $error = true; - } + if ($error) { + break; } $tags[] = $tag['uid']; } if (!empty($error)) { - if ($_POST['_from'] != 'show') { + if (!isset($_POST['_from']) || $_POST['_from'] != 'show') { $this->rc->output->show_message($this->plugin->gettext('untaggingerror'), 'error'); $this->rc->output->command('list_mailbox'); } @@ -227,49 +189,33 @@ class kolab_tags_engine */ public function action_add() { - $tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST); - $storage = $this->rc->get_storage(); - $members = []; - - // build list of members - foreach (rcmail::get_uids() as $mbox => $uids) { - if ($uids === '*') { - $index = $storage->index($mbox, null, null, true); - $uids = $index->get(); - $msgs = $storage->fetch_headers($mbox, $uids, false); - } else { - $msgs = $storage->fetch_headers($mbox, $uids, false); - } - // fetch_headers doesn't detect IMAP errors, so we make sure we get something back. - if (!empty($uids) && empty($msgs)) { - throw new Exception("Failed to find relation members, check the IMAP log."); - } - - $members = array_merge($members, $this->build_members($mbox, $msgs)); - } + $tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST); + $taglist = []; // create a new tag? if (!empty($_POST['_new'])) { $object = [ - 'name' => $tag, - 'members' => $members, + 'name' => $tag, ]; $object = $this->backend->create($object); $error = $object === false; + if ($object) { + $taglist[] = $object; + } } // use existing tags (by UID) else { $filter = [['uid', '=', explode(',', $tag)]]; $taglist = $this->backend->list_tags($filter); + } + if (empty($error)) { // for every tag... foreach ($taglist as $tag) { - $tag['members'] = array_unique(array_merge((array) $tag['members'], $members)); - - // update tag object - if (!$this->backend->update($tag)) { - $error = true; + $error = !$this->backend->add_tag_members($tag, rcmail::get_uids()); + if ($error) { + break; } } } @@ -307,12 +253,6 @@ class kolab_tags_engine public function taglist($attrib) { $taglist = $this->backend->list_tags(); - - // Performance: Save the list for later - if ($this->rc->action == 'show' || $this->rc->action == 'preview') { - $this->taglist = $taglist; - } - $taglist = array_map([$this, 'parse_tag'], $taglist); $this->rc->output->set_env('tags', $taglist); @@ -330,30 +270,7 @@ class kolab_tags_engine return; } - // get tags list - $taglist = $this->backend->list_tags(); - - // get message UIDs - $message_tags = []; - foreach ($args['messages'] as $msg) { - $message_tags[$msg->uid . '-' . $msg->folder] = null; - } - - $uids = array_keys($message_tags); - - foreach ($taglist as $tag) { - $tag = $this->parse_tag($tag, true); - - foreach ((array) $tag['uids'] as $folder => $_uids) { - array_walk($_uids, function (&$uid, $key, $folder) { $uid .= '-' . $folder; }, $folder); - - foreach (array_intersect($uids, $_uids) as $uid) { - $message_tags[$uid][] = $tag['uid']; - } - } - } - - $this->rc->output->set_env('message_tags', array_filter($message_tags)); + $this->rc->output->set_env('message_tags', $this->backend->members_tags($args['messages'])); // @TODO: tag counters for the whole folder (search result) @@ -365,23 +282,22 @@ class kolab_tags_engine */ public function message_headers_handler($args) { - $taglist = $this->taglist ?: $this->backend->list_tags(); - $uid = $args['uid']; - $folder = $args['folder']; - $tags = []; + $taglist = $this->backend->list_tags(); - foreach ($taglist as $tag) { - $tag = $this->parse_tag($tag, true, false); - if (!empty($tag['uids'][$folder]) && in_array($uid, (array) $tag['uids'][$folder])) { - unset($tag['uids']); - $tags[] = $tag; + if (!empty($taglist)) { + $tag_uids = $this->backend->members_tags([$args['headers']]); + + if (!empty($tag_uids)) { + $tag_uids = array_first($tag_uids); + $taglist = array_filter($taglist, function ($tag) use ($tag_uids) { + return in_array($tag['uid'], $tag_uids); + }); + $taglist = array_map([$this, 'parse_tag'], $taglist); + + $this->rc->output->set_env('message_tags', $taglist); } } - if (!empty($tags)) { - $this->rc->output->set_env('message_tags', $tags); - } - return $args; } @@ -402,6 +318,7 @@ class kolab_tags_engine $tags = $this->backend->list_tags([['uid', '=', $args['search_tags']]]); // sanity check (that should not happen) + // TODO: This is driver-specific and should be moved to drivers if (empty($tags)) { if ($orig_folder) { $storage->set_folder($orig_folder); @@ -410,34 +327,27 @@ class kolab_tags_engine return $args; } - $search = []; - $folders = (array) $args['folder']; + $criteria = []; + $folders = $args['folder'] = (array) $args['folder']; + $search = $args['search']; - // collect folders and uids foreach ($tags as $tag) { - $tag = $this->parse_tag($tag, true); + $res = $this->backend->members_search_criteria($tag, $args['folder']); - // tag has no members -> empty search result - if (empty($tag['uids'])) { + if (empty($res)) { + // If any tag has no members in any folder we can skip the other tags goto empty_result; } - foreach ($tag['uids'] as $folder => $uid_list) { - $search[$folder] = array_merge($search[$folder] ?? [], $uid_list); + $criteria = array_intersect_key($criteria, $res); + $args['folder'] = array_keys($res); + + foreach ($res as $folder => $value) { + $current = !empty($criteria[$folder]) ? $criteria[$folder] : trim($search); + $criteria[$folder] = ($current == 'ALL' ? '' : ($current . ' ')) . $value; } } - $search = array_map('array_unique', $search); - $criteria = []; - - // modify search folders/criteria - $args['folder'] = array_intersect($folders, array_keys($search)); - - foreach ($args['folder'] as $folder) { - $criteria[$folder] = ($args['search'] != 'ALL' ? trim($args['search']) . ' ' : '') - . 'UID ' . rcube_imap_generic::compressMessageSet($search[$folder]); - } - if (!empty($args['folder'])) { $args['search'] = $criteria; } else { @@ -445,8 +355,8 @@ class kolab_tags_engine empty_result: if (count($folders) > 1) { - $args['result'] = new rcube_result_multifolder($args['folder']); - foreach ($args['folder'] as $folder) { + $args['result'] = new rcube_result_multifolder($folders); + foreach ($folders as $folder) { $index = new rcube_result_index($folder, '* SORT'); $args['result']->add($index); } @@ -454,7 +364,7 @@ class kolab_tags_engine $class = 'rcube_result_' . ($args['threading'] ? 'thread' : 'index'); $result = $args['threading'] ? '* THREAD' : '* SORT'; - $args['result'] = new $class($args['folder'] ?? 'INBOX', $result); + $args['result'] = new $class(array_first($folders) ?: 'INBOX', $result); } } @@ -488,62 +398,13 @@ class kolab_tags_engine /** * "Convert" tag object to simple array for use in javascript */ - private function parse_tag($tag, $list = false, $force = true) + private function parse_tag($tag) { - $result = [ + return [ 'uid' => $tag['uid'], 'name' => $tag['name'], 'color' => $tag['color'] ?? null, + 'immutableName' => !empty($this->backend->immutableName), ]; - - if ($list) { - $result['uids'] = $this->get_tag_messages($tag, $force); - } - - return $result; - } - - /** - * Resolve members to folder/UID - * - * @param array $tag Tag object - * - * @return array Folder/UID list - */ - protected function get_tag_messages(&$tag, $force = true) - { - return kolab_storage_config::resolve_members($tag, $force); - } - - /** - * Build array of member URIs from set of messages - */ - protected function build_members($folder, $messages) - { - return kolab_storage_config::build_members($folder, $messages); - } - - /** - * Parses tag member string - * - * @param string $url Member URI - * - * @return array Message folder, UID, Search headers (Message-Id, Date) - */ - protected function parse_member_url($url) - { - return kolab_storage_config::parse_member_url($url); - } - - /** - * Builds member URI - * - * @param array $params Message folder, UID, Search headers (Message-Id, Date) - * - * @return string $url Member URI - */ - protected function build_member_url($params) - { - return kolab_storage_config::build_member_url($params); } } diff --git a/plugins/kolab_tags/skins/elastic/templates/ui.html b/plugins/kolab_tags/skins/elastic/templates/ui.html index f0044ae7..a731e0a9 100644 --- a/plugins/kolab_tags/skins/elastic/templates/ui.html +++ b/plugins/kolab_tags/skins/elastic/templates/ui.html @@ -17,8 +17,8 @@ diff --git a/plugins/libcalendaring/tests/VcalendarTest.php b/plugins/libcalendaring/tests/VcalendarTest.php index 3a0595a1..a8065382 100644 --- a/plugins/libcalendaring/tests/VcalendarTest.php +++ b/plugins/libcalendaring/tests/VcalendarTest.php @@ -401,6 +401,7 @@ class VcalendarTest extends PHPUnit\Framework\TestCase $this->assertStringContainsString('UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0', $ics, "Event UID"); $this->assertStringContainsString('SEQUENCE:' . $event['sequence'], $ics, "Export Sequence number"); $this->assertStringContainsString('DESCRIPTION:*Exported by', $ics, "Export Description"); + $this->assertStringContainsString('CATEGORIES:test1,test2', $ics, "VCALENDAR categories property"); $this->assertStringContainsString('ORGANIZER;CN=Rolf Test:mailto:rolf@', $ics, "Export organizer"); $this->assertMatchesRegularExpression('/ATTENDEE.*;ROLE=REQ-PARTICIPANT/', $ics, "Export Attendee ROLE"); $this->assertMatchesRegularExpression('/ATTENDEE.*;PARTSTAT=NEEDS-ACTION/', $ics, "Export Attendee Status"); diff --git a/plugins/libcalendaring/tests/resources/itip.ics b/plugins/libcalendaring/tests/resources/itip.ics index 50eb4ee9..d4258de8 100644 --- a/plugins/libcalendaring/tests/resources/itip.ics +++ b/plugins/libcalendaring/tests/resources/itip.ics @@ -14,6 +14,7 @@ CREATED:20130628T190032Z UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0 LAST-MODIFIED:20130628T190032Z SUMMARY:iTip Test +CATEGORIES:test1,test2 ATTACH;VALUE=BINARY;FMTTYPE=text/html;ENCODING=BASE64; X-LABEL=calendar.html: PCFET0NUWVBFIGh0bWwgUFVCTElDICItLy9XM0MvL0RURCBYSFRNTCAxLjAgVHJhbnNpdGlvbm diff --git a/plugins/libkolab/lib/kolab_storage_cache.php b/plugins/libkolab/lib/kolab_storage_cache.php index 03367c4c..0239ee87 100644 --- a/plugins/libkolab/lib/kolab_storage_cache.php +++ b/plugins/libkolab/lib/kolab_storage_cache.php @@ -777,7 +777,7 @@ class kolab_storage_cache $sql_query = "SELECT " . ($fetchall ? '*' : "`msguid` AS `_msguid`, `uid`") . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" - . $this->_sql_where($query) + . static::sql_where($query) . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); $sql_result = $this->limit ? @@ -866,7 +866,7 @@ class kolab_storage_cache $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` " . - "WHERE `folder_id` = ?" . $this->_sql_where($query), + "WHERE `folder_id` = ?" . static::sql_where($query), $this->folder_id ); @@ -946,40 +946,42 @@ class kolab_storage_cache /** * Helper method to compose a valid SQL query from pseudo filter triplets */ - protected function _sql_where($query) + public static function sql_where($query) { + $db = rcube::get_instance()->get_dbh(); $sql_where = ''; + foreach ((array) $query as $param) { if (is_array($param[0])) { $subq = []; foreach ($param[0] as $q) { - $subq[] = preg_replace('/^\s*AND\s+/i', '', $this->_sql_where([$q])); + $subq[] = preg_replace('/^\s*AND\s+/i', '', static::sql_where([$q])); } if (!empty($subq)) { $sql_where .= ' AND (' . implode($param[1] == 'OR' ? ' OR ' : ' AND ', $subq) . ')'; } continue; } elseif ($param[1] == '=' && is_array($param[2])) { - $qvalue = '(' . implode(',', array_map([$this->db, 'quote'], $param[2])) . ')'; + $qvalue = '(' . implode(',', array_map([$db, 'quote'], $param[2])) . ')'; $param[1] = 'IN'; } elseif ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') { $not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; - $qvalue = $this->db->quote('%' . preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%'); + $qvalue = $db->quote('%' . preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%'); } elseif ($param[1] == '~*' || $param[1] == '!~*') { $not = $param[1][1] == '!' ? 'NOT ' : ''; $param[1] = $not . 'LIKE'; - $qvalue = $this->db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%'); + $qvalue = $db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%'); } elseif ($param[0] == 'tags') { $param[1] = ($param[1] == '!=' ? 'NOT ' : '') . 'LIKE'; - $qvalue = $this->db->quote('% ' . $param[2] . ' %'); + $qvalue = $db->quote('% ' . $param[2] . ' %'); } else { - $qvalue = $this->db->quote($param[2]); + $qvalue = $db->quote($param[2]); } $sql_where .= sprintf( ' AND %s %s %s', - $this->db->quote_identifier($param[0]), + $db->quote_identifier($param[0]), $param[1], $qvalue ); diff --git a/plugins/libkolab/lib/kolab_storage_cache_configuration.php b/plugins/libkolab/lib/kolab_storage_cache_configuration.php index 71229d76..184c73e2 100644 --- a/plugins/libkolab/lib/kolab_storage_cache_configuration.php +++ b/plugins/libkolab/lib/kolab_storage_cache_configuration.php @@ -80,7 +80,7 @@ class kolab_storage_cache_configuration extends kolab_storage_cache /** * Helper method to compose a valid SQL query from pseudo filter triplets */ - protected function _sql_where($query) + public static function sql_where($query) { if (is_array($query)) { foreach ($query as $idx => $param) { @@ -100,7 +100,7 @@ class kolab_storage_cache_configuration extends kolab_storage_cache } } - return parent::_sql_where($query); + return parent::sql_where($query); } /** diff --git a/plugins/libkolab/lib/kolab_storage_config.php b/plugins/libkolab/lib/kolab_storage_config.php index c1b2c31d..c43b42a0 100644 --- a/plugins/libkolab/lib/kolab_storage_config.php +++ b/plugins/libkolab/lib/kolab_storage_config.php @@ -448,7 +448,7 @@ class kolab_storage_config * * @return array Folder/UIDs list */ - public static function resolve_members(&$tag, $force = true) + public static function resolve_members(&$tag, $force = true, $update = true) { $result = []; @@ -558,7 +558,9 @@ class kolab_storage_config // update tag object with new members list $tag['members'] = array_unique($tag['members']); - kolab_storage_config::get_instance()->save($tag, 'relation'); + if ($update) { + kolab_storage_config::get_instance()->save($tag, 'relation'); + } } return $result; diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php index 5d0d192c..b36476fd 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_cache.php +++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php @@ -413,7 +413,7 @@ class kolab_storage_dav_cache extends kolab_storage_cache $sql_query = "SELECT " . ($uids ? "`uid`" : '*') . " FROM `{$this->cache_table}` WHERE `folder_id` = ?" - . $this->_sql_where($query) + . self::sql_where($query) . (!empty($this->order_by) ? " ORDER BY " . $this->order_by : ''); $sql_result = $this->limit ? @@ -454,7 +454,7 @@ class kolab_storage_dav_cache extends kolab_storage_cache $sql_result = $this->db->query( "SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` " . - "WHERE `folder_id` = ?" . $this->_sql_where($query), + "WHERE `folder_id` = ?" . self::sql_where($query), $this->folder_id ); diff --git a/plugins/libkolab/lib/kolab_storage_tags.php b/plugins/libkolab/lib/kolab_storage_tags.php new file mode 100644 index 00000000..16e2691e --- /dev/null +++ b/plugins/libkolab/lib/kolab_storage_tags.php @@ -0,0 +1,474 @@ + + * + * Copyright (C) 2024, Apheleia IT AG + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +class kolab_storage_tags +{ + public const ANNOTATE_KEY_PREFIX = '/vendor/kolab/tag/v1/'; + public const ANNOTATE_VALUE = '1'; + public const METADATA_ROOT = 'INBOX'; + public const METADATA_TAGS_KEY = '/private/vendor/kolab/tags/v1'; + + protected $tag_props = ['name', 'color']; + protected $tags; + + /** + * Class constructor + */ + public function __construct() + { + // FETCH message annotations (tags) by default for better performance + rcube::get_instance()->get_storage()->set_options([ + 'fetch_items' => ['ANNOTATION (' . self::ANNOTATE_KEY_PREFIX . '% (value.priv))'], + ]); + } + + /** + * Tags list + * + * @param array $filter Search filter + * + * @return array List of tags + */ + public function list($filter = []) + { + $tags = $this->list_tags(); + + if (empty($tags)) { + return []; + } + + // For now there's only one type of filter we support + if (empty($filter) || empty($filter[0][0]) || $filter[0][0] != 'uid' || $filter[0][1] != '=') { + return $tags; + } + + $tags = array_filter( + $tags, + function ($tag) use ($filter) { + return $filter[0][0] == 'uid' && in_array($tag['uid'], (array) $filter[0][2]); + } + ); + + return array_values($tags); + } + + /** + * Create tag object + * + * @param array $props Tag properties + * + * @return false|string Tag identifier, or False on failure + */ + public function create($props) + { + $tag = []; + foreach ($this->tag_props as $prop) { + if (isset($props[$prop])) { + $tag[$prop] = $props[$prop]; + } + } + + if (empty($tag['name'])) { + return false; + } + + $uid = md5($tag['name']); + $tags = $this->list_tags(); + + foreach ($tags as $existing_tag) { + if ($existing_tag['uid'] == $uid) { + return false; + } + } + + $tags[] = $tag; + + if (!$this->save_tags($tags)) { + return false; + } + + return $uid; + } + + /** + * Update tag object + * + * @param array $props Tag properties + * + * @return bool True on success, False on failure + */ + public function update($props) + { + $found = null; + foreach ($this->list_tags() as $idx => $existing) { + if ($existing['uid'] == $props['uid']) { + $found = $idx; + } + } + + if ($found === null) { + return false; + } + + $tag = $this->tags[$found]; + + // Name is immutable + if (isset($props['name']) && $props['name'] != $tag['name']) { + return false; + } + + foreach ($this->tag_props as $col) { + if (isset($props[$col])) { + $tag[$col] = $props[$col]; + } + } + + $tags = $this->tags; + $tags[$found] = $tag; + + if (!$this->save_tags($tags)) { + return false; + } + + return true; + } + + /** + * Remove a tag + * + * @param string $uid Tag unique identifier + * + * @return bool True on success, False on failure + */ + public function delete($uid) + { + $found = null; + foreach ($this->list_tags() as $idx => $existing) { + if ($existing['uid'] == $uid) { + $found = $idx; + break; + } + } + + if ($found === null) { + return false; + } + + $tags = $this->tags; + $tag_name = $tags[$found]['name']; + unset($tags[$found]); + + if (!$this->save_tags($tags)) { + return false; + } + + // Remove all message annotations for this tag from all folders + /** @var rcube_imap $imap */ + $imap = rcube::get_instance()->get_storage(); + $search = self::imap_search_criteria($tag_name); + $annotation = []; + $annotation[self::ANNOTATE_KEY_PREFIX . $tag_name] = ['value.priv' => null]; + + foreach ($imap->list_folders() as $folder) { + $index = $imap->search_once($folder, $search); + if ($uids = $index->get_compressed()) { + $imap->annotate_message($annotation, $uids, $folder); + } + } + + return true; + } + + /** + * Returns tag assignments with multiple members + * + * @param array $messages Mail messages + * @param bool $return_names Return tag names instead of UIDs + * + * @return array Assigned tag UIDs or names by message + */ + public function members_tags($messages, $return_names = false) + { + // get tags list + $tag_uids = []; + foreach ($this->list_tags() as $tag) { + $tag_uids[$tag['name']] = $tag['uid']; + } + + if (empty($tag_uids)) { + return []; + } + + $result = []; + $uids = []; + $msg_func = function ($msg) use (&$result, $tag_uids, $return_names) { + if ($msg instanceof rcube_message) { + $msg = $msg->headers; + } + /** @var rcube_message_header $msg */ + if (isset($msg->annotations)) { + $tags = []; + foreach ($msg->annotations as $name => $props) { + if (strpos($name, self::ANNOTATE_KEY_PREFIX) === 0 && !empty($props['value.priv'])) { + $tag_name = substr($name, strlen(self::ANNOTATE_KEY_PREFIX)); + if (isset($tag_uids[$tag_name])) { + $tags[] = $return_names ? $tag_name : $tag_uids[$tag_name]; + } + } + } + + $result[$msg->uid . '-' . $msg->folder] = $tags; + return true; + } + + return false; + }; + + // Check if the annotation is already FETCHED + foreach ($messages as $msg) { + if ($msg_func($msg)) { + continue; + } + + if (!isset($uids[$msg->folder])) { + $uids[$msg->folder] = []; + } + + $uids[$msg->folder][] = $msg->uid; + } + + /** @var rcube_imap $imap */ + $imap = rcube::get_instance()->get_storage(); + $query_items = ['ANNOTATION (' . self::ANNOTATE_KEY_PREFIX . '% (value.priv))']; + + foreach ($uids as $folder => $_uids) { + $fetch = $imap->conn->fetch($folder, $_uids, true, $query_items); + + if ($fetch) { + foreach ($fetch as $msg) { + $msg_func($msg); + } + } + } + + return $result; + } + + /** + * Assign a tag to mail messages + */ + public function add_members(string $uid, string $folder, $uids) + { + if (($tag_name = $this->tag_name_by_uid($uid)) === null) { + return false; + } + + /** @var rcube_imap $imap */ + $imap = rcube::get_instance()->get_storage(); + $annotation = []; + $annotation[self::ANNOTATE_KEY_PREFIX . $tag_name] = ['value.priv' => self::ANNOTATE_VALUE]; + + return $imap->annotate_message($annotation, $uids, $folder); + } + + /** + * Delete a tag from mail messages + */ + public function remove_members(string $uid, string $folder, $uids) + { + if (($tag_name = $this->tag_name_by_uid($uid)) === null) { + return false; + } + + /** @var rcube_imap $imap */ + $imap = rcube::get_instance()->get_storage(); + $annotation = []; + $annotation[self::ANNOTATE_KEY_PREFIX . $tag_name] = ['value.priv' => null]; + + return $imap->annotate_message($annotation, $uids, $folder); + } + + /** + * Update object's tags + * + * @param rcube_message|string $member Kolab object UID or mail message object + * @param array $tags List of tag names + */ + public function set_tags_for($member, $tags) + { + // Only mail for now + if (!$member instanceof rcube_message && !$member instanceof rcube_message_header) { + return []; + } + + $members = $this->members_tags([$member], true); + + $tags = array_unique($tags); + $existing = (array) array_first($members); + $add = array_diff($tags, $existing); + $remove = array_diff($existing, $tags); + $annotations = []; + + if (!empty($remove)) { + foreach ($remove as $tag_name) { + $annotations[self::ANNOTATE_KEY_PREFIX . $tag_name] = ['value.priv' => null]; + } + } + + if (!empty($add)) { + $tags = $this->list_tags(); + $tag_names = array_column($tags, 'name'); + $new = false; + + foreach ($add as $tag_name) { + if (!in_array($tag_name, $tag_names)) { + $tags[] = ['name' => $tag_name]; + $new = true; + } + + $annotations[self::ANNOTATE_KEY_PREFIX . $tag_name] = ['value.priv' => self::ANNOTATE_VALUE]; + } + + if ($new) { + if (!$this->save_tags($tags)) { + return; + } + } + } + + if (!empty($annotations)) { + /** @var rcube_imap $imap */ + $imap = rcube::get_instance()->get_storage(); + $result = $imap->annotate_message($annotations, $member->uid, $member->folder); + + if (!$result) { + rcube::raise_error("Failed to tag/untag a message ({$member->folder}/{$member->uid}. Error: " + . $imap->get_error_str(), true, false); + } + } + } + + /** + * Get tags assigned to a specified object. + * + * @param rcube_message|string $member Kolab object UID or mail message object + * + * @return array List of tag names + */ + public function get_tags_for($member) + { + // Only mail for now + if (!$member instanceof rcube_message && !$member instanceof rcube_message_header) { + return []; + } + + // Get message's tags + $members = $this->members_tags([$member], true); + + return (array) array_first($members); + } + + /** + * Returns IMAP SEARCH item to find messages with specific tag + */ + public static function imap_search_criteria($tag_name) + { + return sprintf( + 'ANNOTATION %s value.priv %s', + rcube_imap_generic::escape(self::ANNOTATE_KEY_PREFIX . $tag_name), + rcube_imap_generic::escape(self::ANNOTATE_VALUE, true) + ); + } + + /** + * Get tags list from the storage (IMAP METADATA on INBOX) + */ + protected function list_tags() + { + if (!isset($this->tags)) { + $imap = rcube::get_instance()->get_storage(); + if (!$imap->get_capability('METADATA')) { + return []; + } + + $this->tags = []; + if ($meta = $imap->get_metadata(self::METADATA_ROOT, self::METADATA_TAGS_KEY)) { + $this->tags = json_decode($meta[self::METADATA_ROOT][self::METADATA_TAGS_KEY], true); + foreach ($this->tags as &$tag) { + $tag['uid'] = md5($tag['name']); + } + } + } + + return $this->tags; + } + + /** + * Get a tag name by uid + */ + protected function tag_name_by_uid($uid) + { + foreach ($this->list_tags() as $tag) { + if ($tag['uid'] === $uid) { + return $tag['name']; + } + } + + return null; + } + + /** + * Store tags list in IMAP metadata + */ + protected function save_tags($tags) + { + $imap = rcube::get_instance()->get_storage(); + if (!$imap->get_capability('METADATA')) { + rcube::raise_error("Failed to store tags. Missing IMAP METADATA support", true, false); + return false; + } + + // Don't store UIDs + foreach ($tags as &$tag) { + unset($tag['uid']); + } + + $tags = array_values($tags); + + $metadata = json_encode($tags, JSON_INVALID_UTF8_IGNORE | JSON_UNESCAPED_UNICODE); + + if (!$imap->set_metadata(self::METADATA_ROOT, [self::METADATA_TAGS_KEY => $metadata])) { + rcube::raise_error("Failed to store tags in IMAP. Error: " . $imap->get_error_str(), true, false); + return false; + } + + // Add the uid back, and update cached list of tags + foreach ($tags as &$tag) { + $tag['uid'] = md5($tag['name']); + } + + $this->tags = $tags; + + return true; + } +} diff --git a/plugins/libkolab/skins/elastic/include/kolab_tags.less b/plugins/libkolab/skins/elastic/include/kolab_tags.less index 71f6b7bc..cb3d4d79 100644 --- a/plugins/libkolab/skins/elastic/include/kolab_tags.less +++ b/plugins/libkolab/skins/elastic/include/kolab_tags.less @@ -93,7 +93,6 @@ max-width: 4em; padding: .1rem .4rem; margin-right: .2rem; - font-weight: bold; &:not(.tagedit-listelement) a { color: inherit; diff --git a/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php index a5d4acb7..aba2d766 100644 --- a/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php +++ b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php @@ -37,6 +37,7 @@ class tasklist_caldav_driver extends tasklist_driver private $lists; private $folders = []; private $tasks = []; + private $tags = []; private $bonnie_api = false; @@ -451,7 +452,7 @@ class tasklist_caldav_driver extends tasklist_driver */ public function get_tags() { - return []; + return $this->tags; } /** @@ -1130,9 +1131,13 @@ class tasklist_caldav_driver extends tasklist_driver 'sequence' => $record['sequence'] ?? null, 'list' => $list_id, 'links' => [], // $record['links'], - 'tags' => [], // $record['tags'], ]; + $task['tags'] = (array) ($record['categories'] ?? []); + if (!empty($task['tags'])) { + $this->tags = array_merge($this->tags, $task['tags']); + } + // convert from DateTime to internal date format if (isset($record['due']) && $record['due'] instanceof DateTimeInterface) { $due = $this->plugin->lib->adjust_timezone($record['due']); @@ -1269,7 +1274,9 @@ class tasklist_caldav_driver extends tasklist_driver $object['sequence'] = $old['sequence']; } - unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']); + $object['categories'] = (array) ($task['tags'] ?? []); + + unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['created']); return $object; } @@ -1536,15 +1543,6 @@ class tasklist_caldav_driver extends tasklist_driver */ public function get_message_reference($uri_or_headers, $folder = null) { - /* - if (is_object($uri_or_headers)) { - $uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder); - } - - if (is_string($uri_or_headers)) { - return kolab_storage_config::get_message_reference($uri_or_headers, 'task'); - } - */ return false; } @@ -1556,16 +1554,6 @@ class tasklist_caldav_driver extends tasklist_driver public function get_message_related_tasks($headers, $folder) { return []; - /* - $config = kolab_storage_config::get_instance(); - $result = $config->get_message_relations($headers, $folder, 'task'); - - foreach ($result as $idx => $rec) { - $result[$idx] = $this->_to_rcube_task($rec, kolab_storage::folder_id($rec['_mailbox'])); - } - - return $result; - */ } /**