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. Requiresb206cbc87a
anda34f716051
Reviewers: #roundcube_kolab_plugins_developers, mollekopf Subscribers: mollekopf, #roundcube_kolab_plugins_developers Differential Revision: https://git.kolab.org/D5032
This commit is contained in:
parent
687ab45110
commit
21ead0149e
22 changed files with 1636 additions and 358 deletions
71
plugins/kolab_tags/README
Normal file
71
plugins/kolab_tags/README
Normal 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/
|
|
@ -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",
|
||||
|
|
4
plugins/kolab_tags/config.inc.php.dist
Normal file
4
plugins/kolab_tags/config.inc.php.dist
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?php
|
||||
|
||||
// Storage backend type (database, kolab, annotate)
|
||||
$config['kolab_tags_driver'] = 'kolab';
|
102
plugins/kolab_tags/drivers/DriverInterface.php
Normal file
102
plugins/kolab_tags/drivers/DriverInterface.php
Normal 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);
|
||||
}
|
196
plugins/kolab_tags/drivers/annotate/Driver.php
Normal file
196
plugins/kolab_tags/drivers/annotate/Driver.php
Normal 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;
|
||||
}
|
||||
}
|
394
plugins/kolab_tags/drivers/database/Driver.php
Normal file
394
plugins/kolab_tags/drivers/database/Driver.php
Normal 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;
|
||||
}
|
||||
}
|
27
plugins/kolab_tags/drivers/database/SQL/mysql.initial.sql
Normal file
27
plugins/kolab_tags/drivers/database/SQL/mysql.initial.sql
Normal 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');
|
268
plugins/kolab_tags/drivers/kolab/Driver.php
Normal file
268
plugins/kolab_tags/drivers/kolab/Driver.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 || {});
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
474
plugins/libkolab/lib/kolab_storage_tags.php
Normal file
474
plugins/libkolab/lib/kolab_storage_tags.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -93,7 +93,6 @@
|
|||
max-width: 4em;
|
||||
padding: .1rem .4rem;
|
||||
margin-right: .2rem;
|
||||
font-weight: bold;
|
||||
|
||||
&:not(.tagedit-listelement) a {
|
||||
color: inherit;
|
||||
|
|
|
@ -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;
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Reference in a new issue