Kolab Tags SQL and ANNOTATE drivers

Summary:
The implementation in the SQL driver sub-optimal as it uses the same member format and internal API as the Kolab driver.
We might need to improve this.

Requires b206cbc87a and a34f716051

Reviewers: #roundcube_kolab_plugins_developers, mollekopf

Subscribers: mollekopf, #roundcube_kolab_plugins_developers

Differential Revision: https://git.kolab.org/D5032
This commit is contained in:
Aleksander Machniak 2024-12-24 09:00:30 +01:00
parent 687ab45110
commit 21ead0149e
22 changed files with 1636 additions and 358 deletions

71
plugins/kolab_tags/README Normal file
View file

@ -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/

View file

@ -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",

View file

@ -0,0 +1,4 @@
<?php
// Storage backend type (database, kolab, annotate)
$config['kolab_tags_driver'] = 'kolab';

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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');

View file

@ -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;
}
}

View file

@ -393,7 +393,7 @@ function tag_form_dialog(id)
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 || {});
}

View file

@ -57,12 +57,12 @@ class kolab_tags extends rcube_plugin
private function engine()
{
if ($this->engine === null) {
// the files module can be enabled/disabled by the kolab_auth plugin
// the plugin can be enabled/disabled by the kolab_auth plugin
if ($this->rc->config->get('kolab_tags_disabled') || !$this->rc->config->get('kolab_tags_enabled', true)) {
return $this->engine = false;
}
// $this->load_config();
$this->load_config();
require_once $this->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_tags_engine.php';
@ -77,13 +77,11 @@ class kolab_tags extends rcube_plugin
*/
public function startup($args)
{
// call this from startup to give a chance to set
// kolab_files_enabled/disabled in kolab_auth plugin
if ($this->rc->output->type != 'html') {
return;
}
if ($engine = $this->engine()) {
if ($this->rc->output->type != 'html') {
return;
}
$engine->ui();
}
}

View file

@ -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);
}
}

View file

@ -26,7 +26,6 @@ class kolab_tags_engine
private $backend;
private $plugin;
private $rc;
private $taglist;
/**
* Class constructor
@ -35,11 +34,16 @@ class kolab_tags_engine
{
$plugin->require_plugin('libkolab');
require_once $plugin->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_tags_backend.php';
$this->backend = new kolab_tags_backend();
$this->plugin = $plugin;
$this->rc = $plugin->rc;
$driver = $this->rc->config->get('kolab_tags_driver') ?: 'database';
$class = "\\KolabTags\\Drivers\\" . ucfirst($driver) . "\\Driver";
require_once "{$plugin->home}/drivers/DriverInterface.php";
require_once "{$plugin->home}/drivers/{$driver}/Driver.php";
$this->backend = new $class();
}
/**
@ -145,7 +149,6 @@ class kolab_tags_engine
$this->rc->output->command('plugin.kolab_tags', $response);
}
$this->rc->output->send();
}
@ -154,65 +157,24 @@ class kolab_tags_engine
*/
public function action_remove()
{
$tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST);
$filter = $tag == '*' ? [] : [['uid', '=', explode(',', $tag)]];
$tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST);
$filter = $tag == '*' ? [] : [['uid', '=', explode(',', $tag)]];
$taglist = $this->backend->list_tags($filter);
$filter = [];
$tags = [];
foreach (rcmail::get_uids() as $mbox => $uids) {
if ($uids === '*') {
$filter[$mbox] = $this->build_member_url(['folder' => $mbox]);
} else {
foreach ((array)$uids as $uid) {
$filter[$mbox][] = $this->build_member_url([
'folder' => $mbox,
'uid' => $uid,
]);
}
}
}
$tags = [];
// for every tag...
foreach ($taglist as $tag) {
$updated = false;
$error = !$this->backend->remove_tag_members($tag, rcmail::get_uids());
// @todo: make sure members list is up-to-date (UIDs are up-to-date)
// ...filter members by folder/uid prefix
foreach ((array) $tag['members'] as $idx => $member) {
foreach ($filter as $members) {
// list of prefixes
if (is_array($members)) {
foreach ($members as $message) {
if ($member == $message || strpos($member, $message . '?') === 0) {
unset($tag['members'][$idx]);
$updated = true;
}
}
}
// one prefix (all messages in a folder)
else {
if (preg_match('/^' . preg_quote($members, '/') . '\/[0-9]+(\?|$)/', $member)) {
unset($tag['members'][$idx]);
$updated = true;
}
}
}
}
// update tag object
if ($updated) {
if (!$this->backend->update($tag)) {
$error = true;
}
if ($error) {
break;
}
$tags[] = $tag['uid'];
}
if (!empty($error)) {
if ($_POST['_from'] != 'show') {
if (!isset($_POST['_from']) || $_POST['_from'] != 'show') {
$this->rc->output->show_message($this->plugin->gettext('untaggingerror'), 'error');
$this->rc->output->command('list_mailbox');
}
@ -227,49 +189,33 @@ class kolab_tags_engine
*/
public function action_add()
{
$tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST);
$storage = $this->rc->get_storage();
$members = [];
// build list of members
foreach (rcmail::get_uids() as $mbox => $uids) {
if ($uids === '*') {
$index = $storage->index($mbox, null, null, true);
$uids = $index->get();
$msgs = $storage->fetch_headers($mbox, $uids, false);
} else {
$msgs = $storage->fetch_headers($mbox, $uids, false);
}
// fetch_headers doesn't detect IMAP errors, so we make sure we get something back.
if (!empty($uids) && empty($msgs)) {
throw new Exception("Failed to find relation members, check the IMAP log.");
}
$members = array_merge($members, $this->build_members($mbox, $msgs));
}
$tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST);
$taglist = [];
// create a new tag?
if (!empty($_POST['_new'])) {
$object = [
'name' => $tag,
'members' => $members,
'name' => $tag,
];
$object = $this->backend->create($object);
$error = $object === false;
if ($object) {
$taglist[] = $object;
}
}
// use existing tags (by UID)
else {
$filter = [['uid', '=', explode(',', $tag)]];
$taglist = $this->backend->list_tags($filter);
}
if (empty($error)) {
// for every tag...
foreach ($taglist as $tag) {
$tag['members'] = array_unique(array_merge((array) $tag['members'], $members));
// update tag object
if (!$this->backend->update($tag)) {
$error = true;
$error = !$this->backend->add_tag_members($tag, rcmail::get_uids());
if ($error) {
break;
}
}
}
@ -307,12 +253,6 @@ class kolab_tags_engine
public function taglist($attrib)
{
$taglist = $this->backend->list_tags();
// Performance: Save the list for later
if ($this->rc->action == 'show' || $this->rc->action == 'preview') {
$this->taglist = $taglist;
}
$taglist = array_map([$this, 'parse_tag'], $taglist);
$this->rc->output->set_env('tags', $taglist);
@ -330,30 +270,7 @@ class kolab_tags_engine
return;
}
// get tags list
$taglist = $this->backend->list_tags();
// get message UIDs
$message_tags = [];
foreach ($args['messages'] as $msg) {
$message_tags[$msg->uid . '-' . $msg->folder] = null;
}
$uids = array_keys($message_tags);
foreach ($taglist as $tag) {
$tag = $this->parse_tag($tag, true);
foreach ((array) $tag['uids'] as $folder => $_uids) {
array_walk($_uids, function (&$uid, $key, $folder) { $uid .= '-' . $folder; }, $folder);
foreach (array_intersect($uids, $_uids) as $uid) {
$message_tags[$uid][] = $tag['uid'];
}
}
}
$this->rc->output->set_env('message_tags', array_filter($message_tags));
$this->rc->output->set_env('message_tags', $this->backend->members_tags($args['messages']));
// @TODO: tag counters for the whole folder (search result)
@ -365,23 +282,22 @@ class kolab_tags_engine
*/
public function message_headers_handler($args)
{
$taglist = $this->taglist ?: $this->backend->list_tags();
$uid = $args['uid'];
$folder = $args['folder'];
$tags = [];
$taglist = $this->backend->list_tags();
foreach ($taglist as $tag) {
$tag = $this->parse_tag($tag, true, false);
if (!empty($tag['uids'][$folder]) && in_array($uid, (array) $tag['uids'][$folder])) {
unset($tag['uids']);
$tags[] = $tag;
if (!empty($taglist)) {
$tag_uids = $this->backend->members_tags([$args['headers']]);
if (!empty($tag_uids)) {
$tag_uids = array_first($tag_uids);
$taglist = array_filter($taglist, function ($tag) use ($tag_uids) {
return in_array($tag['uid'], $tag_uids);
});
$taglist = array_map([$this, 'parse_tag'], $taglist);
$this->rc->output->set_env('message_tags', $taglist);
}
}
if (!empty($tags)) {
$this->rc->output->set_env('message_tags', $tags);
}
return $args;
}
@ -402,6 +318,7 @@ class kolab_tags_engine
$tags = $this->backend->list_tags([['uid', '=', $args['search_tags']]]);
// sanity check (that should not happen)
// TODO: This is driver-specific and should be moved to drivers
if (empty($tags)) {
if ($orig_folder) {
$storage->set_folder($orig_folder);
@ -410,34 +327,27 @@ class kolab_tags_engine
return $args;
}
$search = [];
$folders = (array) $args['folder'];
$criteria = [];
$folders = $args['folder'] = (array) $args['folder'];
$search = $args['search'];
// collect folders and uids
foreach ($tags as $tag) {
$tag = $this->parse_tag($tag, true);
$res = $this->backend->members_search_criteria($tag, $args['folder']);
// tag has no members -> empty search result
if (empty($tag['uids'])) {
if (empty($res)) {
// If any tag has no members in any folder we can skip the other tags
goto empty_result;
}
foreach ($tag['uids'] as $folder => $uid_list) {
$search[$folder] = array_merge($search[$folder] ?? [], $uid_list);
$criteria = array_intersect_key($criteria, $res);
$args['folder'] = array_keys($res);
foreach ($res as $folder => $value) {
$current = !empty($criteria[$folder]) ? $criteria[$folder] : trim($search);
$criteria[$folder] = ($current == 'ALL' ? '' : ($current . ' ')) . $value;
}
}
$search = array_map('array_unique', $search);
$criteria = [];
// modify search folders/criteria
$args['folder'] = array_intersect($folders, array_keys($search));
foreach ($args['folder'] as $folder) {
$criteria[$folder] = ($args['search'] != 'ALL' ? trim($args['search']) . ' ' : '')
. 'UID ' . rcube_imap_generic::compressMessageSet($search[$folder]);
}
if (!empty($args['folder'])) {
$args['search'] = $criteria;
} else {
@ -445,8 +355,8 @@ class kolab_tags_engine
empty_result:
if (count($folders) > 1) {
$args['result'] = new rcube_result_multifolder($args['folder']);
foreach ($args['folder'] as $folder) {
$args['result'] = new rcube_result_multifolder($folders);
foreach ($folders as $folder) {
$index = new rcube_result_index($folder, '* SORT');
$args['result']->add($index);
}
@ -454,7 +364,7 @@ class kolab_tags_engine
$class = 'rcube_result_' . ($args['threading'] ? 'thread' : 'index');
$result = $args['threading'] ? '* THREAD' : '* SORT';
$args['result'] = new $class($args['folder'] ?? 'INBOX', $result);
$args['result'] = new $class(array_first($folders) ?: 'INBOX', $result);
}
}
@ -488,62 +398,13 @@ class kolab_tags_engine
/**
* "Convert" tag object to simple array for use in javascript
*/
private function parse_tag($tag, $list = false, $force = true)
private function parse_tag($tag)
{
$result = [
return [
'uid' => $tag['uid'],
'name' => $tag['name'],
'color' => $tag['color'] ?? null,
'immutableName' => !empty($this->backend->immutableName),
];
if ($list) {
$result['uids'] = $this->get_tag_messages($tag, $force);
}
return $result;
}
/**
* Resolve members to folder/UID
*
* @param array $tag Tag object
*
* @return array Folder/UID list
*/
protected function get_tag_messages(&$tag, $force = true)
{
return kolab_storage_config::resolve_members($tag, $force);
}
/**
* Build array of member URIs from set of messages
*/
protected function build_members($folder, $messages)
{
return kolab_storage_config::build_members($folder, $messages);
}
/**
* Parses tag member string
*
* @param string $url Member URI
*
* @return array Message folder, UID, Search headers (Message-Id, Date)
*/
protected function parse_member_url($url)
{
return kolab_storage_config::parse_member_url($url);
}
/**
* Builds member URI
*
* @param array $params Message folder, UID, Search headers (Message-Id, Date)
*
* @return string $url Member URI
*/
protected function build_member_url($params)
{
return kolab_storage_config::build_member_url($params);
}
}

View file

@ -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>

View file

@ -401,6 +401,7 @@ class VcalendarTest extends PHPUnit\Framework\TestCase
$this->assertStringContainsString('UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0', $ics, "Event UID");
$this->assertStringContainsString('SEQUENCE:' . $event['sequence'], $ics, "Export Sequence number");
$this->assertStringContainsString('DESCRIPTION:*Exported by', $ics, "Export Description");
$this->assertStringContainsString('CATEGORIES:test1,test2', $ics, "VCALENDAR categories property");
$this->assertStringContainsString('ORGANIZER;CN=Rolf Test:mailto:rolf@', $ics, "Export organizer");
$this->assertMatchesRegularExpression('/ATTENDEE.*;ROLE=REQ-PARTICIPANT/', $ics, "Export Attendee ROLE");
$this->assertMatchesRegularExpression('/ATTENDEE.*;PARTSTAT=NEEDS-ACTION/', $ics, "Export Attendee Status");

View file

@ -14,6 +14,7 @@ CREATED:20130628T190032Z
UID:ac6b0aee-2519-4e5c-9a25-48c57064c9f0
LAST-MODIFIED:20130628T190032Z
SUMMARY:iTip Test
CATEGORIES:test1,test2
ATTACH;VALUE=BINARY;FMTTYPE=text/html;ENCODING=BASE64;
X-LABEL=calendar.html:
PCFET0NUWVBFIGh0bWwgUFVCTElDICItLy9XM0MvL0RURCBYSFRNTCAxLjAgVHJhbnNpdGlvbm

View file

@ -777,7 +777,7 @@ class kolab_storage_cache
$sql_query = "SELECT " . ($fetchall ? '*' : "`msguid` AS `_msguid`, `uid`")
. " FROM `{$this->cache_table}` WHERE `folder_id` = ?"
. $this->_sql_where($query)
. static::sql_where($query)
. (!empty($this->order_by) ? " ORDER BY " . $this->order_by : '');
$sql_result = $this->limit ?
@ -866,7 +866,7 @@ class kolab_storage_cache
$sql_result = $this->db->query(
"SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` " .
"WHERE `folder_id` = ?" . $this->_sql_where($query),
"WHERE `folder_id` = ?" . static::sql_where($query),
$this->folder_id
);
@ -946,40 +946,42 @@ class kolab_storage_cache
/**
* Helper method to compose a valid SQL query from pseudo filter triplets
*/
protected function _sql_where($query)
public static function sql_where($query)
{
$db = rcube::get_instance()->get_dbh();
$sql_where = '';
foreach ((array) $query as $param) {
if (is_array($param[0])) {
$subq = [];
foreach ($param[0] as $q) {
$subq[] = preg_replace('/^\s*AND\s+/i', '', $this->_sql_where([$q]));
$subq[] = preg_replace('/^\s*AND\s+/i', '', static::sql_where([$q]));
}
if (!empty($subq)) {
$sql_where .= ' AND (' . implode($param[1] == 'OR' ? ' OR ' : ' AND ', $subq) . ')';
}
continue;
} elseif ($param[1] == '=' && is_array($param[2])) {
$qvalue = '(' . implode(',', array_map([$this->db, 'quote'], $param[2])) . ')';
$qvalue = '(' . implode(',', array_map([$db, 'quote'], $param[2])) . ')';
$param[1] = 'IN';
} elseif ($param[1] == '~' || $param[1] == 'LIKE' || $param[1] == '!~' || $param[1] == '!LIKE') {
$not = ($param[1] == '!~' || $param[1] == '!LIKE') ? 'NOT ' : '';
$param[1] = $not . 'LIKE';
$qvalue = $this->db->quote('%' . preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%');
$qvalue = $db->quote('%' . preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%');
} elseif ($param[1] == '~*' || $param[1] == '!~*') {
$not = $param[1][1] == '!' ? 'NOT ' : '';
$param[1] = $not . 'LIKE';
$qvalue = $this->db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%');
$qvalue = $db->quote(preg_replace('/(^\^|\$$)/', ' ', $param[2]) . '%');
} elseif ($param[0] == 'tags') {
$param[1] = ($param[1] == '!=' ? 'NOT ' : '') . 'LIKE';
$qvalue = $this->db->quote('% ' . $param[2] . ' %');
$qvalue = $db->quote('% ' . $param[2] . ' %');
} else {
$qvalue = $this->db->quote($param[2]);
$qvalue = $db->quote($param[2]);
}
$sql_where .= sprintf(
' AND %s %s %s',
$this->db->quote_identifier($param[0]),
$db->quote_identifier($param[0]),
$param[1],
$qvalue
);

View file

@ -80,7 +80,7 @@ class kolab_storage_cache_configuration extends kolab_storage_cache
/**
* Helper method to compose a valid SQL query from pseudo filter triplets
*/
protected function _sql_where($query)
public static function sql_where($query)
{
if (is_array($query)) {
foreach ($query as $idx => $param) {
@ -100,7 +100,7 @@ class kolab_storage_cache_configuration extends kolab_storage_cache
}
}
return parent::_sql_where($query);
return parent::sql_where($query);
}
/**

View file

@ -448,7 +448,7 @@ class kolab_storage_config
*
* @return array Folder/UIDs list
*/
public static function resolve_members(&$tag, $force = true)
public static function resolve_members(&$tag, $force = true, $update = true)
{
$result = [];
@ -558,7 +558,9 @@ class kolab_storage_config
// update tag object with new members list
$tag['members'] = array_unique($tag['members']);
kolab_storage_config::get_instance()->save($tag, 'relation');
if ($update) {
kolab_storage_config::get_instance()->save($tag, 'relation');
}
}
return $result;

View file

@ -413,7 +413,7 @@ class kolab_storage_dav_cache extends kolab_storage_cache
$sql_query = "SELECT " . ($uids ? "`uid`" : '*')
. " FROM `{$this->cache_table}` WHERE `folder_id` = ?"
. $this->_sql_where($query)
. self::sql_where($query)
. (!empty($this->order_by) ? " ORDER BY " . $this->order_by : '');
$sql_result = $this->limit ?
@ -454,7 +454,7 @@ class kolab_storage_dav_cache extends kolab_storage_cache
$sql_result = $this->db->query(
"SELECT COUNT(*) AS `numrows` FROM `{$this->cache_table}` " .
"WHERE `folder_id` = ?" . $this->_sql_where($query),
"WHERE `folder_id` = ?" . self::sql_where($query),
$this->folder_id
);

View file

@ -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;
}
}

View file

@ -93,7 +93,6 @@
max-width: 4em;
padding: .1rem .4rem;
margin-right: .2rem;
font-weight: bold;
&:not(.tagedit-listelement) a {
color: inherit;

View file

@ -37,6 +37,7 @@ class tasklist_caldav_driver extends tasklist_driver
private $lists;
private $folders = [];
private $tasks = [];
private $tags = [];
private $bonnie_api = false;
@ -451,7 +452,7 @@ class tasklist_caldav_driver extends tasklist_driver
*/
public function get_tags()
{
return [];
return $this->tags;
}
/**
@ -1130,9 +1131,13 @@ class tasklist_caldav_driver extends tasklist_driver
'sequence' => $record['sequence'] ?? null,
'list' => $list_id,
'links' => [], // $record['links'],
'tags' => [], // $record['tags'],
];
$task['tags'] = (array) ($record['categories'] ?? []);
if (!empty($task['tags'])) {
$this->tags = array_merge($this->tags, $task['tags']);
}
// convert from DateTime to internal date format
if (isset($record['due']) && $record['due'] instanceof DateTimeInterface) {
$due = $this->plugin->lib->adjust_timezone($record['due']);
@ -1269,7 +1274,9 @@ class tasklist_caldav_driver extends tasklist_driver
$object['sequence'] = $old['sequence'];
}
unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['tags'], $object['created']);
$object['categories'] = (array) ($task['tags'] ?? []);
unset($object['tempid'], $object['raw'], $object['list'], $object['flagged'], $object['created']);
return $object;
}
@ -1536,15 +1543,6 @@ class tasklist_caldav_driver extends tasklist_driver
*/
public function get_message_reference($uri_or_headers, $folder = null)
{
/*
if (is_object($uri_or_headers)) {
$uri_or_headers = kolab_storage_config::get_message_uri($uri_or_headers, $folder);
}
if (is_string($uri_or_headers)) {
return kolab_storage_config::get_message_reference($uri_or_headers, 'task');
}
*/
return false;
}
@ -1556,16 +1554,6 @@ class tasklist_caldav_driver extends tasklist_driver
public function get_message_related_tasks($headers, $folder)
{
return [];
/*
$config = kolab_storage_config::get_instance();
$result = $config->get_message_relations($headers, $folder, 'task');
foreach ($result as $idx => $rec) {
$result[$idx] = $this->_to_rcube_task($rec, kolab_storage::folder_id($rec['_mailbox']));
}
return $result;
*/
}
/**