Page MenuHomePhorge

D5032.1775287061.diff
No OneTemporary

Authored By
Unknown
Size
77 KB
Referenced Files
None
Subscribers
None

D5032.1775287061.diff

diff --git a/plugins/kolab_tags/README b/plugins/kolab_tags/README
new file mode 100644
--- /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 /<path-to-roundcube>/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
--- 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
--- /dev/null
+++ b/plugins/kolab_tags/config.inc.php.dist
@@ -0,0 +1,4 @@
+<?php
+
+// Storage backend type (database, kolab, annotate)
+$config['kolab_tags_driver'] = 'kolab';
diff --git a/plugins/kolab_tags/drivers/DriverInterface.php b/plugins/kolab_tags/drivers/DriverInterface.php
new file mode 100644
--- /dev/null
+++ b/plugins/kolab_tags/drivers/DriverInterface.php
@@ -0,0 +1,102 @@
+<?php
+
+/**
+ * Kolab Tags backend driver interface
+ *
+ * @author Aleksander Machniak <machniak@kolabsys.com>
+ *
+ * Copyright (C) 2024, 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/>.
+ */
+
+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<string> 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<string, 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
--- /dev/null
+++ b/plugins/kolab_tags/drivers/annotate/Driver.php
@@ -0,0 +1,196 @@
+<?php
+
+/**
+ * Kolab Tags backend driver for IMAP ANNOTATE (and METADATA). E.g. Kolab4.
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2024, 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/>.
+ */
+
+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<string> 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<rcube_message_header> $messages Mail messages
+ *
+ * @return array<string, 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
--- /dev/null
+++ b/plugins/kolab_tags/drivers/database/Driver.php
@@ -0,0 +1,394 @@
+<?php
+
+/**
+ * Kolab Tags backend driver for SQL database.
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2024, 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/>.
+ */
+
+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<string> 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<rcube_message_header> $messages Mail messages
+ *
+ * @return array<string, 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
--- /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
--- /dev/null
+++ b/plugins/kolab_tags/drivers/kolab/Driver.php
@@ -0,0 +1,268 @@
+<?php
+
+/**
+ * Kolab Tags backend driver for Kolab v3
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2024, 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/>.
+ */
+
+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<string> 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<rcube_message_header> $messages Mail messages
+ *
+ * @return array<string, 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
--- a/plugins/kolab_tags/kolab_tags.js
+++ b/plugins/kolab_tags/kolab_tags.js
@@ -393,7 +393,7 @@
content.append(row1.append(name_label).append($('<div class="col-sm-10">').append(name_input)))
.append(row2.append(color_label).append($('<div class="col-sm-10">').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
--- a/plugins/kolab_tags/kolab_tags.php
+++ b/plugins/kolab_tags/kolab_tags.php
@@ -57,12 +57,12 @@
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 @@
*/
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
--- a/plugins/kolab_tags/lib/kolab_tags_backend.php
+++ /dev/null
@@ -1,110 +0,0 @@
-<?php
-
-/**
- * Kolab Tags backend
- *
- * @author Aleksander Machniak <machniak@kolabsys.com>
- *
- * Copyright (C) 2014, Kolab Systems AG <contact@kolabsys.com>
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-class 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
--- a/plugins/kolab_tags/lib/kolab_tags_engine.php
+++ b/plugins/kolab_tags/lib/kolab_tags_engine.php
@@ -26,7 +26,6 @@
private $backend;
private $plugin;
private $rc;
- private $taglist;
/**
* Class constructor
@@ -35,11 +34,16 @@
{
$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 @@
$this->rc->output->command('plugin.kolab_tags', $response);
}
-
$this->rc->output->send();
}
@@ -154,65 +157,24 @@
*/
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;
-
- // @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;
- }
- }
- }
- }
+ $error = !$this->backend->remove_tag_members($tag, rcmail::get_uids());
- // 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 @@
*/
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 @@
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 @@
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,21 +282,20 @@
*/
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($tags)) {
- $this->rc->output->set_env('message_tags', $tags);
+ 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);
+ }
}
return $args;
@@ -402,6 +318,7 @@
$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,32 +327,25 @@
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);
- $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]);
+ foreach ($res as $folder => $value) {
+ $current = !empty($criteria[$folder]) ? $criteria[$folder] : trim($search);
+ $criteria[$folder] = ($current == 'ALL' ? '' : ($current . ' ')) . $value;
+ }
}
if (!empty($args['folder'])) {
@@ -445,8 +355,8 @@
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 = '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 @@
/**
* "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
--- a/plugins/kolab_tags/skins/elastic/templates/ui.html
+++ b/plugins/kolab_tags/skins/elastic/templates/ui.html
@@ -17,8 +17,8 @@
<div id="tagmessagemenu" class="popupmenu" aria-hidden="true">
<ul class="menu iconized">
<li class="separator"><label><roundcube:label name="kolab_tags.tags" /></label></li>
- <roundcube:button type="link-menuitem" command="tag-add" label="kolab_tags.tagadd" classAct="tag add active" class="tag add disabled" />
- <roundcube:button type="link-menuitem" command="tag-remove" label="kolab_tags.tagremove" classAct="tag remove active" class="tag remove disabled" />
+ <roundcube:button type="link-menuitem" command="tag-add" label="kolab_tags.tagadd" classAct="tag add active" class="tag add disabled" innerclass="inner" aria-haspopup="true" />
+ <roundcube:button type="link-menuitem" command="tag-remove" label="kolab_tags.tagremove" classAct="tag remove active" class="tag remove disabled" innerclass="inner" aria-haspopup="true" />
<roundcube:button type="link-menuitem" command="tag-remove-all" label="kolab_tags.tagremoveall" classAct="tag remove all active" class="tag remove all disabled" />
</ul>
</div>
diff --git a/plugins/libcalendaring/tests/VcalendarTest.php b/plugins/libcalendaring/tests/VcalendarTest.php
--- a/plugins/libcalendaring/tests/VcalendarTest.php
+++ b/plugins/libcalendaring/tests/VcalendarTest.php
@@ -401,6 +401,7 @@
$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
--- a/plugins/libcalendaring/tests/resources/itip.ics
+++ b/plugins/libcalendaring/tests/resources/itip.ics
@@ -14,6 +14,7 @@
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
--- a/plugins/libkolab/lib/kolab_storage_cache.php
+++ b/plugins/libkolab/lib/kolab_storage_cache.php
@@ -777,7 +777,7 @@
$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 @@
$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 @@
/**
* 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
--- a/plugins/libkolab/lib/kolab_storage_cache_configuration.php
+++ b/plugins/libkolab/lib/kolab_storage_cache_configuration.php
@@ -80,7 +80,7 @@
/**
* 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 @@
}
}
- 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
--- a/plugins/libkolab/lib/kolab_storage_config.php
+++ b/plugins/libkolab/lib/kolab_storage_config.php
@@ -448,7 +448,7 @@
*
* @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 @@
// 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
--- a/plugins/libkolab/lib/kolab_storage_dav_cache.php
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php
@@ -413,7 +413,7 @@
$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 @@
$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
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_tags.php
@@ -0,0 +1,474 @@
+<?php
+
+/**
+ * Kolab storage class providing access to tags stored in IMAP (Kolab4-style)
+ *
+ * @author Aleksander Machniak <machniak@kolabsys.com>
+ *
+ * Copyright (C) 2024, 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_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<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<rcube_message_header|rcube_message> $messages Mail messages
+ * @param bool $return_names Return tag names instead of UIDs
+ *
+ * @return array<string, 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<int, string> 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
--- 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
--- a/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php
+++ b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php
@@ -37,6 +37,7 @@
private $lists;
private $folders = [];
private $tasks = [];
+ private $tags = [];
private $bonnie_api = false;
@@ -451,7 +452,7 @@
*/
public function get_tags()
{
- return [];
+ return $this->tags;
}
/**
@@ -1130,9 +1131,13 @@
'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 @@
$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 @@
*/
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 @@
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;
- */
}
/**

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 7:17 AM (14 h, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18825565
Default Alt Text
D5032.1775287061.diff (77 KB)

Event Timeline