Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117774102
D5032.1775240542.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
77 KB
Referenced Files
None
Subscribers
None
D5032.1775240542.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 6:22 PM (10 h, 2 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18825565
Default Alt Text
D5032.1775240542.diff (77 KB)
Attached To
Mode
D5032: Kolab Tags SQL and ANNOTATE drivers
Attached
Detach File
Event Timeline