Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117749272
D5832.1775179852.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
142 KB
Referenced Files
None
Subscribers
None
D5832.1775179852.diff
View Options
diff --git a/plugins/kolab/Kolab/CockpitLink.php b/plugins/kolab/Kolab/CockpitLink.php
--- a/plugins/kolab/Kolab/CockpitLink.php
+++ b/plugins/kolab/Kolab/CockpitLink.php
@@ -20,7 +20,8 @@
*/
public function readyHook($args): array
{
- if ($this->rc->output->type !== 'html' || !empty($this->rc->output->env['framed'])) {
+ // Note: Under Syncroton output might not be set
+ if ($this->rc->output?->type !== 'html' || !empty($this->rc->output?->env['framed'])) {
return $args;
}
diff --git a/plugins/kolab_activesync/kolab_activesync.php b/plugins/kolab_activesync/kolab_activesync.php
--- a/plugins/kolab_activesync/kolab_activesync.php
+++ b/plugins/kolab_activesync/kolab_activesync.php
@@ -26,7 +26,7 @@
class kolab_activesync extends rcube_plugin
{
/** @var string Defines when the plugin is being used */
- public $task = '(addressbook|calendar|settings|tasks)';
+ public $task = '(addressbook|calendar|notes|settings|tasks)';
/** @var ?kolab_subscriptions Subscriptions storage */
public $engine;
diff --git a/plugins/kolab_notes/README b/plugins/kolab_notes/README
new file mode 100644
--- /dev/null
+++ b/plugins/kolab_notes/README
@@ -0,0 +1,70 @@
+A notes module for Roundcube
+--------------------------------------
+
+This plugin currently supports two storage backends:
+- "kolab": Kolab Groupware v3 Server
+- "webdav": WebDAV Server (required support for some custom properties), e.g. Kolab v4
+
+
+REQUIREMENTS
+------------
+
+Some functions are shared with other plugins and therefore being moved to
+library plugins. Thus in order to run the kolab_notes 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_notes .
+ $ cp -r /tmp/roundcubemail-plugins-kolab/plugins/libkolab .
+
+2. Create kolab_notes plugin configuration
+
+ $ cd kolab_notes/
+ $ cp config.inc.php.dist config.inc.php
+ $ edit config.inc.php
+
+3. Initialize the libkolab plugin database tables
+
+ $ cd ../../
+ $ bin/initdb.sh --dir=plugins/libkolab/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_notes' to the list of active plugins:
+
+ $config['plugins'] = [
+ (...)
+ 'kolab_notes',
+ ];
+
+
+IMPORTANT
+---------
+
+This plugin doesn't work with the Classic skin of Roundcube because no
+templates are available for that skin.
+
+Use Roundcube `skins_allowed` option to limit skins available to the user
+or remove incompatible skins from the skins folder.
+
+[1] https://git.kolab.org/diffusion/RPK/
diff --git a/plugins/kolab_notes/config.inc.php.dist b/plugins/kolab_notes/config.inc.php.dist
new file mode 100644
--- /dev/null
+++ b/plugins/kolab_notes/config.inc.php.dist
@@ -0,0 +1,8 @@
+<?php
+
+// Backend type (kolab, webdav)
+$config['kolab_notes_driver'] = 'kolab';
+
+// WebDAV server location (required when kolab_notes_driver = webdav)
+// Example for Kolab v4 WebDAV: 'http://domain.tld/dav'
+$config['kolab_notes_webdav_server'] = 'http://localhost';
diff --git a/plugins/kolab_notes/drivers/kolab/kolab_notes_driver.php b/plugins/kolab_notes/drivers/kolab/kolab_notes_driver.php
new file mode 100644
--- /dev/null
+++ b/plugins/kolab_notes/drivers/kolab/kolab_notes_driver.php
@@ -0,0 +1,1028 @@
+<?php
+
+/**
+ * Kolab notes driver for Kolab v3 format (IMAP)
+ *
+ * @author Thomas Bruederli <bruederli@kolabsys.com>
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2014-2025, Apheleia IT <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_notes_driver
+{
+ public $has_more = false;
+ public $subscriptions = true;
+
+ protected $cache = [];
+ protected $bonnie_api = false;
+ protected $lists;
+ protected $folders;
+ protected $plugin;
+ protected $rc;
+
+ /**
+ * Driver constructor.
+ *
+ * @param kolab_notes $plugin Plugin object
+ */
+ public function __construct($plugin)
+ {
+ $this->rc = rcube::get_instance();
+ $this->plugin = $plugin;
+
+ $this->plugin->require_plugin('libkolab');
+
+ // get configuration for the Bonnie API
+ $this->bonnie_api = libkolab::get_bonnie_api();
+
+ // notes use fully encoded identifiers
+ kolab_storage::$encode_ids = true;
+ }
+
+ /**
+ * Read available folders for the current user and store them internally
+ */
+ private function _read_lists($force = false)
+ {
+ // already read sources
+ if (isset($this->lists) && !$force) {
+ return $this->lists;
+ }
+
+ // get all folders that have type "note"
+ $folders = kolab_storage::sort_folders(kolab_storage::get_folders('note'));
+ $this->lists = $this->folders = [];
+
+ // find default folder
+ $default_index = 0;
+ foreach ($folders as $i => $folder) {
+ if ($folder->default) {
+ $default_index = $i;
+ }
+ }
+
+ // put default folder on top of the list
+ if ($default_index > 0) {
+ $default_folder = $folders[$default_index];
+ unset($folders[$default_index]);
+ array_unshift($folders, $default_folder);
+ }
+
+ foreach ($folders as $folder) {
+ $item = $this->folder_props($folder);
+ $this->lists[$item['id']] = $item;
+ $this->folders[$item['id']] = $folder;
+ $this->folders[$folder->name] = $folder;
+ }
+ }
+
+ /**
+ * Get a list of available folders from this source
+ *
+ * @return array List of note folders
+ */
+ public function get_lists(&$tree = null)
+ {
+ $this->_read_lists();
+
+ // attempt to create a default folder for this user
+ if (empty($this->lists)) {
+ $folder = ['name' => 'Notes', 'type' => 'note', 'default' => true, 'subscribed' => true];
+ if (kolab_storage::folder_update($folder)) {
+ $this->_read_lists(true);
+ }
+ }
+
+ $folders = [];
+ foreach ($this->lists as $id => $list) {
+ if (!empty($this->folders[$id])) {
+ $folders[] = $this->folders[$id];
+ }
+ }
+
+ // include virtual folders for a full folder tree
+ if (!is_null($tree)) {
+ $folders = kolab_storage::folder_hierarchy($folders, $tree);
+ }
+
+ $lists = [];
+
+ foreach ($folders as $folder) {
+ $list_id = $folder->id;
+ $parent_id = $this->folder_parent_id($folder);
+ $fullname = $folder->get_name();
+ $listname = $folder->get_foldername();
+
+ // special handling for virtual folders
+ if ($folder instanceof kolab_storage_folder_user) {
+ $lists[$list_id] = [
+ 'id' => $list_id,
+ 'name' => $fullname,
+ 'listname' => $listname,
+ 'title' => $folder->get_title(),
+ 'virtual' => true,
+ 'editable' => false,
+ 'rights' => 'l',
+ 'group' => 'other virtual',
+ 'class' => 'user',
+ 'parent' => $parent_id,
+ ];
+ } elseif ($folder instanceof kolab_storage_folder_virtual) {
+ $lists[$list_id] = [
+ 'id' => $list_id,
+ 'name' => $fullname,
+ 'listname' => $listname,
+ 'virtual' => true,
+ 'editable' => false,
+ 'rights' => 'l',
+ 'group' => $folder->get_namespace(),
+ 'parent' => $parent_id,
+ ];
+ } else {
+ if (empty($this->lists[$list_id])) {
+ $this->lists[$list_id] = $this->folder_props($folder);
+ $this->folders[$list_id] = $folder;
+ }
+ $this->lists[$list_id]['parent'] = $parent_id;
+ $lists[$list_id] = $this->lists[$list_id];
+ }
+ }
+
+ return $lists;
+ }
+
+ /**
+ * Search for shared or otherwise not listed folders the user has access
+ *
+ * @param string $query Search string
+ * @param string $source Section/source to search
+ *
+ * @return array List of note folders
+ */
+ public function search_lists($query, $source)
+ {
+ if (!kolab_storage::setup()) {
+ return [];
+ }
+
+ $this->has_more = false;
+ $this->lists = $this->folders = [];
+
+ // find unsubscribed IMAP folders of "note" type
+ if ($source == 'folders') {
+ foreach ((array)kolab_storage::search_folders('note', $query, ['other']) as $folder) {
+ $this->folders[$folder->id] = $folder;
+ $this->lists[$folder->id] = $this->folder_props($folder);
+ }
+ }
+ // search other user's namespace via LDAP
+ elseif ($source == 'users') {
+ $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number
+ foreach (kolab_storage::search_users($query, 0, [], $limit * 10) as $user) {
+ $folders = [];
+ // search for note folders shared by this user
+ foreach (kolab_storage::list_user_folders($user, 'note', false) as $foldername) {
+ $folders[] = new kolab_storage_folder($foldername, 'note');
+ }
+
+ $count = 0;
+ if (count($folders)) {
+ $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user);
+ $this->folders[$userfolder->id] = $userfolder;
+ $this->lists[$userfolder->id] = $this->folder_props($userfolder);
+
+ foreach ($folders as $folder) {
+ $this->folders[$folder->id] = $folder;
+ $this->lists[$folder->id] = $this->folder_props($folder);
+ $count++;
+ }
+ }
+
+ if ($count >= $limit) {
+ $this->has_more = true;
+ break;
+ }
+ }
+
+ }
+
+ return $this->get_lists();
+ }
+
+ protected function folder_parent_id($folder)
+ {
+ if (empty($folder)) {
+ return null;
+ }
+
+ $this->_read_lists();
+
+ $delim = $this->rc->get_storage()->get_hierarchy_delimiter();
+ $imap_path = explode($delim, $folder->name);
+
+ // find parent
+ do {
+ array_pop($imap_path);
+ $parent_id = kolab_storage::folder_id(implode($delim, $imap_path));
+ } while (count($imap_path) > 1 && empty($this->folders[$parent_id]));
+
+ // restore "real" parent ID
+ if ($parent_id && empty($this->folders[$parent_id])) {
+ $parent_id = kolab_storage::folder_id($folder->get_parent());
+ }
+
+ return $parent_id;
+ }
+
+ /**
+ * Derive list properties from the given kolab_storage_folder object
+ */
+ protected function folder_props($folder)
+ {
+ if ($folder->get_namespace() == 'personal') {
+ $norename = false;
+ $editable = true;
+ $rights = 'lrswikxtea';
+ $alarms = true;
+ } else {
+ $alarms = false;
+ $rights = 'lr';
+ $editable = false;
+ if (($myrights = $folder->get_myrights()) && !PEAR::isError($myrights)) {
+ $rights = $myrights;
+ if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) {
+ $editable = strpos($rights, 'i');
+ }
+ }
+ $info = $folder->get_folder_info();
+ $norename = !$editable || $info['norename'] || $info['protected'];
+ }
+
+ $list_id = $folder->id;
+
+ return [
+ 'id' => $list_id,
+ 'name' => $folder->get_name(),
+ 'listname' => $folder->get_foldername(),
+ 'editname' => $folder->get_foldername(),
+ 'editable' => $editable,
+ 'rights' => $rights,
+ 'norename' => $norename,
+ 'parentfolder' => $folder->get_parent(),
+ 'subscribed' => (bool)$folder->is_subscribed(),
+ 'default' => $folder->default,
+ 'group' => $folder->default ? 'default' : $folder->get_namespace(),
+ 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')),
+ ];
+ }
+
+ /**
+ * Get the kolab_calendar instance for the given calendar ID
+ *
+ * @param string $id List identifier (encoded imap folder name)
+ *
+ * @return ?kolab_storage_folder Object nor null if list doesn't exist
+ */
+ protected function get_folder($id)
+ {
+ // $this->_read_lists();
+
+ // create list and folder instance if necessary
+ if (empty($this->lists[$id])) {
+ $folder = kolab_storage::get_folder(kolab_storage::id_decode($id));
+ if ($folder->type) {
+ $this->folders[$id] = $folder;
+ $this->lists[$id] = $this->folder_props($folder);
+ }
+ }
+
+ return $this->folders[$id] ?? null;
+ }
+
+ /**
+ * Returns last error message
+ *
+ * @return ?string
+ */
+ public function last_error()
+ {
+ return kolab_storage::$last_error ? $this->plugin->gettext(kolab_storage::$last_error) : null;
+ }
+
+ /**
+ * Create a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_create(&$list)
+ {
+ $list['type'] = 'note';
+ $folder = kolab_storage::folder_update($list);
+
+ if ($folder === false) {
+ return false;
+ }
+
+ $list['id'] = kolab_storage::folder_id($folder);
+
+ return true;
+ }
+
+ /**
+ * Get a list (notebook) properties
+ *
+ * @param string $list_id Notebook identifier
+ *
+ * @return ?array
+ */
+ public function list_get($list_id)
+ {
+ if ($folder = $this->get_folder($list_id)) {
+ return $this->lists[$list_id];
+ }
+
+ return null;
+ }
+
+ /**
+ * Update a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_update(&$list)
+ {
+ $this->_read_lists();
+
+ $parent_id = $this->folder_parent_id($this->get_folder($list['id']));
+ $newfolder = kolab_storage::folder_update($list);
+
+ if ($newfolder === false) {
+ return false;
+ }
+
+ $list['id'] = kolab_storage::folder_id($newfolder);
+
+ // compose the new display name
+ $delim = $this->rc->get_storage()->get_hierarchy_delimiter();
+ $path_imap = explode($delim, $newfolder);
+ $list['name'] = kolab_storage::object_name($newfolder);
+ $list['editname'] = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP');
+ $list['listname'] = $list['editname'];
+ $list['parent'] = $this->folder_parent_id(new kolab_storage_folder($newfolder, 'note'));
+
+ return true;
+ }
+
+ /**
+ * Delete a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_delete($list)
+ {
+ $folder = $this->get_folder($list['id']);
+
+ return $folder && kolab_storage::folder_delete($folder->name);
+ }
+
+ /**
+ * Subscibe a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_subscribe($list)
+ {
+ $success = false;
+
+ if (!empty($list['id']) && ($folder = $this->get_folder($list['id']))) {
+ if (isset($list['permanent'])) {
+ $success |= $folder->subscribe((int) $list['permanent']);
+ }
+ if (isset($list['active'])) {
+ $success |= $folder->activate((int) $list['active']);
+ }
+
+ // apply to child folders, too
+ if ($list['recursive']) {
+ foreach ((array)kolab_storage::list_folders($folder->name, '*', 'node') as $subfolder) {
+ if (isset($list['permanent'])) {
+ ($list['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder));
+ }
+ if (isset($list['active'])) {
+ ($list['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder));
+ }
+ }
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * Read note records for the given list from the storage backend
+ */
+ public function list_notes($list_id, $search = null)
+ {
+ $results = [];
+
+ // query Kolab storage
+ $query = [];
+
+ // full text search (only works with cache enabled)
+ if (strlen($search)) {
+ $words = array_filter(rcube_utils::normalize_string(mb_strtolower($search), true));
+ foreach ($words as $word) {
+ if (strlen($word) > 2) { // only words > 3 chars are stored in DB
+ $query[] = ['words', '~', $word];
+ }
+ }
+ }
+
+ $this->_read_lists();
+
+ if ($folder = $this->get_folder($list_id)) {
+ foreach ($folder->select($query, empty($query)) as $record) {
+ // post-filter search results
+ if (strlen($search)) {
+ $matches = 0;
+ $desc = $this->plugin->is_html($record) ? strip_tags($record['description']) : ($record['description'] ?? '');
+ $contents = mb_strtolower($record['title'] . $desc);
+
+ foreach ($words as $word) {
+ if (mb_strpos($contents, $word) !== false) {
+ $matches++;
+ }
+ }
+
+ // skip records not matching all search words
+ if ($matches < count($words)) {
+ continue;
+ }
+ }
+ $record['list'] = $list_id;
+ $results[] = $record;
+ }
+ }
+
+ if (count($results)) {
+ $config = kolab_storage_config::get_instance();
+ $config->apply_tags($results);
+ $config->apply_links($results);
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get the full note record identified by the given UID + Lolder identifier
+ *
+ * @param array|string $note Note identifier or note properties (uid + list)
+ *
+ * @return false|array Note properties if found, False otherwise
+ */
+ public function get_note($note)
+ {
+ if (is_array($note)) {
+ $uid = $note['uid'] ?: $note['id'];
+ $list_id = $note['list'];
+ } else {
+ $uid = $note;
+ }
+
+ // deliver from in-memory cache
+ if (!empty($list_id) && !empty($this->cache["$list_id:$uid"])) {
+ return $this->cache["$list_id:$uid"];
+ }
+
+ $result = false;
+
+ if (!empty($list_id)) {
+ if ($folder = $this->get_folder($list_id)) {
+ $result = $folder->get_object($uid);
+ }
+ } else {
+ $this->_read_lists();
+ foreach ($this->folders as $list_id => $folder) {
+ if ($result = $folder->get_object($uid)) {
+ $result['list'] = $list_id;
+ break;
+ }
+ }
+ }
+
+ if ($result) {
+ $result['tags'] = $this->get_tags($result['uid']);
+ $result['links'] = $this->get_links($result['uid']);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Update a note record with the given data
+ *
+ * @param array $note Hash array with note properties (uid, list)
+ *
+ * @return bool True on success, False on error
+ */
+ public function save_note(&$note)
+ {
+ $list_id = $note['list'];
+ if (!$list_id || !($folder = $this->get_folder($list_id))) {
+ return false;
+ }
+
+ // load previous version of this record to merge
+ $old = null;
+ if (!empty($note['uid'])) {
+ $old = $folder->get_object($note['uid']);
+ if (!$old || PEAR::isError($old)) {
+ return false;
+ }
+
+ // merge existing properties if the update isn't complete
+ if (!isset($note['title']) || !isset($note['description'])) {
+ $note += $old;
+ }
+ }
+
+ // generate new note object from input
+ $object = $this->write_preprocess($note, $old);
+
+ // email links and tags are handled separately
+ $links = $object['links'] ?? null;
+ $tags = $object['tags'] ?? null;
+
+ unset($object['links']);
+ unset($object['tags']);
+
+ $saved = $folder->save($object, 'note', $note['uid']);
+
+ if (!$saved) {
+ rcube::raise_error(
+ [
+ 'code' => 600, 'type' => 'php',
+ 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving note object to Kolab server"],
+ true,
+ false
+ );
+ $saved = false;
+ } else {
+ // save links in configuration.relation object
+ $this->save_links($object['uid'], $links);
+ // save tags in configuration.relation object
+ $this->save_tags($object['uid'], $tags);
+
+ $note = $object;
+ $note['list'] = $list_id;
+ $note['tags'] = (array) $tags;
+
+ // cache this in memory for later read
+ $key = $list_id . ':' . $note['uid'];
+ $this->cache[$key] = $note;
+ }
+
+ return $saved;
+ }
+
+ /**
+ * Move the given note to another folder
+ */
+ public function move_note($note, $list_id)
+ {
+ $this->_read_lists();
+
+ $tofolder = $this->get_folder($list_id);
+ $fromfolder = $this->get_folder($note['list']);
+
+ if ($fromfolder && $tofolder) {
+ return $fromfolder->move($note['uid'], $tofolder);
+ }
+
+ return false;
+ }
+
+ /**
+ * Remove a single note record from the backend
+ *
+ * @param array $note Hash array with note properties (id, list)
+ * @param bool $force Remove record irreversible (mark as deleted otherwise)
+ *
+ * @return bool True on success, False on error
+ */
+ public function delete_note($note, $force = true)
+ {
+ $list_id = $note['list'];
+ if (!$list_id || !($folder = $this->get_folder($list_id))) {
+ return false;
+ }
+
+ $status = $folder->delete($note['uid'], $force);
+
+ if ($status) {
+ $this->save_links($note['uid'], null);
+ $this->save_tags($note['uid'], null);
+ }
+
+ return $status;
+ }
+
+ /**
+ * Provide a list of revisions for the given object
+ *
+ * @param array $note Hash array with note properties
+ *
+ * @return array|false List of changes, each as a hash array
+ */
+ public function get_changelog($note)
+ {
+ if (empty($this->bonnie_api)) {
+ return false;
+ }
+
+ [$uid, $mailbox, $msguid] = $this->resolve_note_identity($note);
+
+ $result = $uid && $mailbox ? $this->bonnie_api->changelog('note', $uid, $mailbox, $msguid) : null;
+ if (is_array($result) && $result['uid'] == $uid) {
+ return $result['changes'];
+ }
+
+ return false;
+ }
+
+ /**
+ * Return full data of a specific revision of a note record
+ *
+ * @param mixed $note UID string or hash array with note properties
+ * @param mixed $rev Revision number
+ *
+ * @return array|false Note object as hash array
+ */
+ public function get_revison($note, $rev)
+ {
+ if (empty($this->bonnie_api)) {
+ return false;
+ }
+
+ [$uid, $mailbox, $msguid] = $this->resolve_note_identity($note);
+
+ // call Bonnie API
+ $result = $this->bonnie_api->get('note', $uid, $rev, $mailbox, $msguid);
+ if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) {
+ $format = kolab_format::factory('note');
+ $format->load($result['xml']);
+ $rec = $format->to_array();
+
+ if ($format->is_valid()) {
+ $rec['rev'] = $result['rev'];
+ return $rec;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get a list of property changes beteen two revisions of a note object
+ *
+ * @param array $note Hash array with note properties
+ * @param mixed $rev1 Revision: "from"
+ * @param mixed $rev2 Revision: "to"
+ *
+ * @return array|false List of property changes, each as a hash array
+ */
+ public function get_diff($note, $rev1, $rev2)
+ {
+ if (empty($this->bonnie_api)) {
+ return false;
+ }
+
+ [$uid, $mailbox, $msguid] = $this->resolve_note_identity($note);
+
+ // call Bonnie API
+ $result = $this->bonnie_api->diff('note', $uid, $rev1, $rev2, $mailbox, $msguid);
+ if (is_array($result) && $result['uid'] == $uid) {
+ $result['rev1'] = $rev1;
+ $result['rev2'] = $rev2;
+
+ // convert some properties, similar to self::_client_encode()
+ $keymap = [
+ 'summary' => 'title',
+ 'lastmodified-date' => 'changed',
+ ];
+
+ // map kolab object properties to keys and values the client expects
+ array_walk($result['changes'], function (&$change, $i) use ($keymap) {
+ if (array_key_exists($change['property'], $keymap)) {
+ $change['property'] = $keymap[$change['property']];
+ }
+
+ if ($change['property'] == 'created' || $change['property'] == 'changed') {
+ if ($old_ = rcube_utils::anytodatetime($change['old'])) {
+ $change['old_'] = $this->rc->format_date($old_);
+ }
+ if ($new_ = rcube_utils::anytodatetime($change['new'])) {
+ $change['new_'] = $this->rc->format_date($new_);
+ }
+ }
+
+ // compute a nice diff of note contents
+ if ($change['property'] == 'description') {
+ $change['diff_'] = libkolab::html_diff($change['old'], $change['new']);
+ if (!empty($change['diff_'])) {
+ unset($change['old'], $change['new']);
+ $change['diff_'] = preg_replace(['!^.*<body[^>]*>!Uims','!</body>.*$!Uims'], '', $change['diff_']);
+ $change['diff_'] = preg_replace("!</(p|li|span)>\n!", '</\\1>', $change['diff_']);
+ }
+ }
+ });
+
+ return $result;
+ }
+
+ return false;
+ }
+
+ /**
+ * Command the backend to restore a certain revision of a note.
+ * This shall replace the current object with an older version.
+ *
+ * @param array $note Hash array with note properties (id, list)
+ * @param mixed $rev Revision number
+ *
+ * @return bool True on success, False on failure
+ */
+ public function restore_revision($note, $rev)
+ {
+ if (empty($this->bonnie_api)) {
+ return false;
+ }
+
+ [$uid, $mailbox, $msguid] = $this->resolve_note_identity($note);
+
+ $folder = $this->get_folder($note['list']);
+ $success = false;
+
+ if ($folder && ($raw_msg = $this->bonnie_api->rawdata('note', $uid, $rev, $mailbox))) {
+ $imap = $this->rc->get_storage();
+
+ // insert $raw_msg as new message
+ if ($imap->save_message($folder->name, $raw_msg, null, false)) {
+ $success = true;
+
+ // delete old revision from imap and cache
+ $imap->delete_message($msguid, $folder->name);
+ $folder->cache->set($msguid, false);
+ $this->cache = [];
+ }
+ }
+
+ return $success;
+ }
+
+ /**
+ * Helper method to resolved the given note identifier into uid and mailbox
+ *
+ * @return array (uid,mailbox,msguid) tuple
+ */
+ private function resolve_note_identity($note)
+ {
+ $mailbox = $msguid = null;
+
+ if (!is_array($note)) {
+ $note = $this->get_note($note);
+ }
+
+ if (is_array($note)) {
+ $uid = $note['uid'] ?: $note['id'];
+ $list = $note['list'];
+ } else {
+ return [null, $mailbox, $msguid];
+ }
+
+ if (!empty($list) && ($folder = $this->get_folder($list))) {
+ $mailbox = $folder->get_mailbox_id();
+
+ // get object from storage in order to get the real object uid an msguid
+ if ($rec = $folder->get_object($uid)) {
+ $msguid = $rec['_msguid'];
+ $uid = $rec['uid'];
+ }
+ }
+
+ return [$uid, $mailbox, $msguid];
+ }
+
+ /**
+ * Find mail messages assigned to a specified note
+ */
+ private function get_links($uid)
+ {
+ $config = kolab_storage_config::get_instance();
+ return $config->get_object_links($uid);
+ }
+
+ /**
+ * Find notes assigned to a specified mail message
+ *
+ * @param rcube_message_header $message Message headers
+ *
+ * @return array<array> List of notes
+ */
+ public function get_message_notes($message)
+ {
+ $config = kolab_storage_config::get_instance();
+ $result = $config->get_message_relations($message, $message->folder, 'note');
+
+ foreach ($result as $idx => $note) {
+ $result[$idx]['list'] = kolab_storage::folder_id($note['_mailbox']);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get mail message reference
+ *
+ * @param rcube_message_header|string $message Message headers or an URI
+ *
+ * @return false|array Reference info
+ */
+ public function get_message_reference($message)
+ {
+ return kolab_storage_config::get_message_reference(
+ is_string($message) ? $message : kolab_storage_config::get_message_uri($message, $message->folder),
+ 'note'
+ );
+ }
+
+ /**
+ * Get note tags
+ */
+ private function get_tags($uid)
+ {
+ $config = kolab_storage_config::get_instance();
+ $tags = $config->get_tags($uid);
+ $tags = array_map(function ($v) { return $v['name']; }, $tags);
+
+ return $tags;
+ }
+
+ /**
+ * Save note-to-mail links
+ */
+ private function save_links($uid, $links)
+ {
+ $config = kolab_storage_config::get_instance();
+ return $config->save_object_links($uid, (array) $links);
+ }
+
+ /**
+ * Update note tags
+ */
+ private function save_tags($uid, $tags)
+ {
+ $config = kolab_storage_config::get_instance();
+ $config->save_tags($uid, $tags);
+ }
+
+ /**
+ * Create form for notebook creation/edition
+ *
+ * @param ?string $list_id List (notebook) identifier
+ *
+ * @return string Form HTML code
+ */
+ public function list_form($list_id)
+ {
+ $folder = $list_id ? $this->get_folder($list_id) : null;
+
+ $folder_name = $folder ? $folder->get_name() : '';
+ $hidden_fields[] = ['name' => 'oldname', 'value' => $folder_name];
+
+ $storage = $this->rc->get_storage();
+ $delim = $storage->get_hierarchy_delimiter();
+ $form = [];
+
+ if (strlen($folder_name)) {
+ $options = $storage->folder_info($folder_name);
+
+ $path_imap = explode($delim, $folder_name);
+ array_pop($path_imap); // pop off name part
+ $path_imap = implode($delim, $path_imap);
+ } else {
+ $path_imap = '';
+ $options = [];
+ }
+
+ // General tab
+ $form['properties'] = [
+ 'name' => $this->rc->gettext('properties'),
+ 'fields' => [],
+ ];
+
+ // folder name (default field)
+ $input_name = new html_inputfield(['name' => 'name', 'id' => 'noteslist-name', 'size' => 20]);
+ $form['properties']['fields']['name'] = [
+ 'label' => $this->plugin->gettext('listname'),
+ 'value' => $input_name->show(
+ $folder ? $folder->get_foldername() : '',
+ ['disabled' => !empty($options['norename']) || !empty($options['protected'])]
+ ),
+ 'id' => 'noteslist-name',
+ ];
+
+ // prevent user from moving folder
+ if (!empty($options['norename']) || !empty($options['protected'])) {
+ $hidden_fields[] = ['name' => 'parent', 'value' => $path_imap];
+ } else {
+ $select = kolab_storage::folder_selector('note', ['name' => 'parent', 'id' => 'parent-folder'], $folder_name);
+ $form['properties']['fields']['path'] = [
+ 'label' => $this->plugin->gettext('parentfolder'),
+ 'value' => $select->show(strlen($folder_name) ? $path_imap : ''),
+ 'id' => 'parent-folder',
+ ];
+ }
+
+ return kolab_utils::folder_form($form, $folder_name, 'kolab_notes', $hidden_fields);
+ }
+
+ /**
+ * Process the given note data (submitted by the client) before saving it
+ */
+ protected function write_preprocess($note, $old = [])
+ {
+ $object = $note;
+
+ // TODO: handle attachments
+
+ // convert link references into simple URIs
+ if (array_key_exists('links', $note)) {
+ $object['links'] = array_map(function ($link) { return is_array($link) ? $link['uri'] : strval($link); }, $note['links']);
+ } else {
+ if ($old) {
+ $object['links'] = $old['links'] ?? null;
+ }
+ }
+
+ // clean up HTML content
+ $object['description'] = $this->plugin->wash_html($object['description']);
+ $is_html = true;
+
+ // try to be smart and convert to plain-text if no real formatting is detected
+ if (preg_match('!<body><(?:p|pre)>(.*)</(?:p|pre)></body>!Uims', $object['description'], $m)) {
+ if (!preg_match('!<(a|b|i|strong|em|p|span|div|pre|li|img)(\s+[a-z]|>)!im', $m[1], $n)
+ || ($n[1] != 'img' && !strpos($m[1], '</' . $n[1] . '>'))
+ ) {
+ // $converter = new rcube_html2text($m[1], false, true, 0);
+ // $object['description'] = rtrim($converter->get_text());
+ $object['description'] = html_entity_decode(preg_replace('!<br(\s+/)>!', "\n", $m[1]));
+ $is_html = false;
+ }
+ }
+
+ // Add proper HTML header, otherwise Kontact renders it as plain text
+ if ($is_html) {
+ $object['description'] = "<!DOCTYPE html>\n"
+ . str_replace('<head>', '<head><meta name="qrichtext" content="1" />', $object['description']);
+ }
+
+ // copy meta data (starting with _) from old object
+ foreach ((array) $old as $key => $val) {
+ if (!isset($object[$key]) && $key[0] == '_') {
+ $object[$key] = $val;
+ }
+ }
+
+ // make list of categories unique
+ if (!empty($object['tags'])) {
+ $object['tags'] = array_unique(array_filter($object['tags']));
+ }
+
+ unset($object['list'], $object['tempid'], $object['created'], $object['changed'], $object['created_'], $object['changed_']);
+ return $object;
+ }
+}
diff --git a/plugins/kolab_notes/drivers/webdav/webdav_notes_driver.php b/plugins/kolab_notes/drivers/webdav/webdav_notes_driver.php
new file mode 100644
--- /dev/null
+++ b/plugins/kolab_notes/drivers/webdav/webdav_notes_driver.php
@@ -0,0 +1,604 @@
+<?php
+
+/**
+ * Kolab notes driver for WebDAV
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2014-2026, Apheleia IT <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/>.
+ */
+
+require_once __DIR__ . '/../kolab/kolab_notes_driver.php';
+
+class webdav_notes_driver extends kolab_notes_driver
+{
+ public $subscriptions = false;
+
+ protected $storage;
+ protected $tags;
+
+ /**
+ * Driver constructor.
+ *
+ * @param kolab_notes $plugin Plugin object
+ */
+ public function __construct($plugin)
+ {
+ parent::__construct($plugin);
+
+ // Initialize the WebDAV storage
+ $url = $this->rc->config->get('kolab_notes_webdav_server', 'http://localhost');
+ $url = str_replace('%u', $_SESSION['username'], $url);
+
+ $this->storage = new kolab_storage_dav($url);
+ }
+
+ /**
+ * Read available folders for the current user and store them internally
+ */
+ private function _read_lists($force = false)
+ {
+ // already read sources
+ if (isset($this->lists) && !$force) {
+ return $this->lists;
+ }
+
+ $folders = $this->storage->get_folders('note');
+
+ // find default folder
+ $default_index = 0;
+ foreach ($folders as $i => $folder) {
+ if ($folder->default) {
+ $default_index = $i;
+ }
+ }
+
+ // put default folder on top of the list
+ if ($default_index > 0) {
+ $default_folder = $folders[$default_index];
+ unset($folders[$default_index]);
+ array_unshift($folders, $default_folder);
+ }
+
+ $this->lists = $this->folders = [];
+
+ foreach ($folders as $folder) {
+ $item = $this->folder_props($folder);
+ $this->lists[$item['id']] = $item;
+ $this->folders[$item['id']] = $folder;
+ }
+ }
+
+ /**
+ * Get a list of available folders from this source
+ *
+ * @return array List of note folders
+ */
+ public function get_lists(&$tree = null)
+ {
+ $this->_read_lists();
+
+ // attempt to create a default folder for this user
+ if (empty($this->lists)) {
+ $folder = ['name' => 'Notes', 'type' => 'note'];
+ if ($this->storage->folder_update($folder)) {
+ $this->_read_lists(true);
+ }
+ }
+
+ $folders = [];
+ $lists = [];
+
+ foreach ($this->lists as $id => $list) {
+ if (!empty($this->folders[$id])) {
+ $folders[] = $this->folders[$id];
+ }
+ }
+
+ foreach ($folders as $folder) {
+ $list_id = $folder->id;
+
+ if (empty($this->lists[$list_id])) {
+ $this->lists[$list_id] = $this->folder_props($folder);
+ $this->folders[$list_id] = $folder;
+ }
+
+ $this->lists[$list_id]['parent'] = null;
+ $lists[$list_id] = $this->lists[$list_id];
+ }
+
+ return $lists;
+ }
+
+ /**
+ * Search for shared or otherwise not listed folders the user has access to
+ *
+ * @param string $query Search string
+ * @param string $source Section/source to search
+ *
+ * @return array List of note folders
+ */
+ public function search_lists($query, $source)
+ {
+ return [];
+ }
+
+ /**
+ * Derive list properties from the given kolab_storage_folder object
+ */
+ protected function folder_props($folder)
+ {
+ $ns = $folder->get_namespace();
+ if ($ns == 'personal') {
+ $norename = false;
+ $editable = true;
+ $rights = 'lrswikxtea';
+ } else {
+ $rights = 'lr';
+ $editable = false;
+ $myrights = $folder->get_myrights();
+ $rights = $myrights;
+ $editable = strpos($rights, 'i') !== false;
+ $norename = !$editable;
+ }
+
+ return [
+ 'id' => $folder->id,
+ 'name' => $folder->get_name(),
+ 'listname' => $folder->get_name(),
+ 'editname' => $folder->get_foldername(),
+ 'editable' => $editable,
+ 'rights' => $rights,
+ 'norename' => $norename,
+ // 'subscribed' => (bool) $folder->is_subscribed(),
+ 'default' => $folder->default,
+ 'group' => $folder->default ? 'default' : $ns,
+ 'class' => trim($ns . ($folder->default ? ' default' : '')),
+ ];
+ }
+
+ /**
+ * Get the kolab_calendar instance for the given calendar ID
+ *
+ * @param string $id List identifier (encoded imap folder name)
+ *
+ * @return ?kolab_storage_folder Object nor null if list doesn't exist
+ */
+ protected function get_folder($id)
+ {
+ // create list and folder instance if necessary
+ if (empty($this->lists[$id])) {
+ if ($folder = $this->storage->get_folder($id, 'note')) {
+ $this->folders[$id] = $folder;
+ $this->lists[$id] = $this->folder_props($folder);
+ }
+ }
+
+ return $this->folders[$id] ?? null;
+ }
+
+ /**
+ * Returns last error message
+ *
+ * @return ?string
+ */
+ public function last_error()
+ {
+ return kolab_storage_dav::$last_error ? $this->plugin->gettext(kolab_storage_dav::$last_error) : null;
+ }
+
+ /**
+ * Create a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_create(&$list)
+ {
+ return $this->list_update($list);
+ }
+
+ /**
+ * Update a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_update(&$list)
+ {
+ $list['type'] = 'note';
+ $folder_id = $this->storage->folder_update($list);
+
+ if ($folder_id === false) {
+ return false;
+ }
+
+ $list['id'] = $folder_id;
+ $list['editname'] = $list['name'];
+ $list['listname'] = $list['editname'];
+ $list['parent'] = null;
+
+ return true;
+ }
+
+ /**
+ * Delete a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_delete($list)
+ {
+ return $this->storage->folder_delete($list['id'], 'note');
+ }
+
+ /**
+ * Subscibe a list (notebook)
+ *
+ * @param array $list Notebook properties
+ *
+ * @return bool
+ */
+ public function list_subscribe($list)
+ {
+ return false;
+ }
+
+ /**
+ * Create form for notebook creation/edition
+ *
+ * @param ?string $list_id List (notebook) identifier
+ *
+ * @return string Form HTML code
+ */
+ public function list_form($list_id)
+ {
+ $folder = $list_id ? $this->get_folder($list_id) : null;
+
+ $input_name = new html_inputfield(['name' => 'name', 'id' => 'noteslist-name', 'size' => 20]);
+
+ $form = [
+ 'properties' => [
+ 'name' => $this->rc->gettext('properties'),
+ 'fields' => [
+ 'name' => [
+ 'label' => $this->plugin->gettext('listname'),
+ 'value' => $input_name->show($folder ? $folder->get_foldername() : ''),
+ 'id' => 'noteslist-name',
+ ],
+ ],
+ ],
+ ];
+
+ return kolab_utils::folder_form($form, $folder ?? null, 'kolab_notes', [], true);
+ }
+
+ /**
+ * Read note records for the given list from the storage backend
+ */
+ public function list_notes($list_id, $search = null)
+ {
+ $results = [];
+ $query = [];
+
+ // full text search (only works with cache enabled)
+ if (strlen($search)) {
+ $words = array_filter(rcube_utils::normalize_string(mb_strtolower($search), true));
+ foreach ($words as $word) {
+ if (strlen($word) > 2) { // only words > 3 chars are stored in DB
+ $query[] = ['words', '~', $word];
+ }
+ }
+ }
+
+ $this->_read_lists();
+
+ if ($folder = $this->get_folder($list_id)) {
+ foreach ($folder->select($query, true) as $record) {
+ // TODO: Kolab driver post-filters search results to return only these
+ // that match all search words
+ $results[] = $this->read_postprocess($record, $list_id);
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get the full note record identified by the given UID + Lolder identifier
+ *
+ * @param array|string $note Note identifier or note properties (uid + list)
+ *
+ * @return false|array Note properties if found, False otherwise
+ */
+ public function get_note($note)
+ {
+ if (is_array($note)) {
+ $uid = $note['uid'];
+ $list_id = $note['list'];
+ } else {
+ $uid = $note;
+ }
+
+ // deliver from in-memory cache
+ if (!empty($list_id) && !empty($this->cache["$list_id:$uid"])) {
+ return $this->cache["$list_id:$uid"];
+ }
+
+ $result = false;
+
+ if (!empty($list_id)) {
+ if ($folder = $this->get_folder($list_id)) {
+ $result = $folder->get_object($uid);
+ }
+ } else {
+ $this->_read_lists();
+ foreach ($this->folders as $list_id => $folder) {
+ if ($result = $folder->get_object($uid)) {
+ break;
+ }
+ }
+ }
+
+ if ($result) {
+ $result = $this->read_postprocess($result, $list_id);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Update a note record with the given data
+ *
+ * @param array $note Hash array with note properties (uid, list)
+ *
+ * @return bool True on success, False on error
+ */
+ public function save_note(&$note)
+ {
+ $list_id = $note['list'] ?? null;
+ if (!$list_id || !($folder = $this->get_folder($list_id))) {
+ return false;
+ }
+
+ // load previous version of this record to merge
+ $old = null;
+ if (!empty($note['uid'])) {
+ $old = $folder->get_object($note['uid']);
+ if (!$old || PEAR::isError($old)) {
+ return false;
+ }
+
+ // merge existing properties if the update isn't complete
+ if (!isset($note['title']) || !isset($note['description'])) {
+ $note += $old;
+ }
+ }
+
+ $object = $this->write_preprocess($note, $old);
+
+ $saved = $folder->save($object, 'note', $note['uid'] ?? null);
+
+ if (!$saved) {
+ rcube::raise_error(
+ [
+ 'code' => 600, 'type' => 'php',
+ 'file' => __FILE__, 'line' => __LINE__,
+ 'message' => "Error saving note object to DAV server"],
+ true,
+ false
+ );
+ $saved = false;
+ } else {
+ $note = $this->read_postprocess($object, $list_id);
+ if ($old && !empty($old['created'])) {
+ $note['created'] = $old['created'];
+ }
+
+ // cache this in memory for later read
+ $key = $list_id . ':' . $note['uid'];
+ $this->cache[$key] = $note;
+ }
+
+ return $saved;
+ }
+
+ /**
+ * Remove a single note record from the backend
+ *
+ * @param array $note Hash array with note properties (id, list)
+ * @param bool $force Remove record irreversible (mark as deleted otherwise)
+ *
+ * @return bool True on success, False on error
+ */
+ public function delete_note($note, $force = true)
+ {
+ $list_id = $note['list'];
+ if (!$list_id || !($folder = $this->get_folder($list_id))) {
+ return false;
+ }
+
+ return $folder->delete($note['uid'], $force);
+ }
+
+ /**
+ * Provide a list of revisions for the given object
+ *
+ * @param array $note Hash array with note properties
+ *
+ * @return array|false List of changes, each as a hash array
+ */
+ public function get_changelog($note)
+ {
+ return false;
+ }
+
+ /**
+ * Return full data of a specific revision of a note record
+ *
+ * @param mixed $note UID string or hash array with note properties
+ * @param mixed $rev Revision number
+ *
+ * @return array|false Note object as hash array
+ */
+ public function get_revison($note, $rev)
+ {
+ return false;
+ }
+
+ /**
+ * Get a list of property changes beteen two revisions of a note object
+ *
+ * @param array $note Hash array with note properties
+ * @param mixed $rev1 Revision: "from"
+ * @param mixed $rev2 Revision: "to"
+ *
+ * @return array|false List of property changes, each as a hash array
+ */
+ public function get_diff($note, $rev1, $rev2)
+ {
+ return false;
+ }
+
+ /**
+ * Command the backend to restore a certain revision of a note.
+ * This shall replace the current object with an older version.
+ *
+ * @param array $note Hash array with note properties (id, list)
+ * @param mixed $rev Revision number
+ *
+ * @return bool True on success, False on failure
+ */
+ public function restore_revision($note, $rev)
+ {
+ return false;
+ }
+
+ /**
+ * Find notes assigned to a specified mail message
+ *
+ * @param rcube_message_header $message Message headers
+ *
+ * @return array<array> List of notes
+ */
+ public function get_message_notes($message)
+ {
+ $uri = kolab_storage_config::get_message_uri($message, $message->folder);
+
+ $url = parse_url(str_replace(':///', '://', $uri));
+
+ if ($url['scheme'] != 'imap') {
+ return [];
+ }
+
+ parse_str($url['query'], $param);
+
+ // See kolab_storage_dav_cache_note::get_tags()
+ $query = [['tags', '=', 'ref:' . trim($param['message-id'] ?: urldecode($url['fragment']), '<> ')]];
+ $results = [];
+
+ $this->_read_lists();
+
+ foreach ($this->folders as $list_id => $folder) {
+ foreach ($folder->select($query, true) as $record) {
+ $results[] = $this->read_postprocess($record, $list_id);
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Parse list of categories
+ */
+ private function parse_categories($list, $create = true)
+ {
+ // If kolab_tags plugin is enabled and configured to use the 'annotate' driver...
+ // TODO: Support other drivers (e.g. the 'database' driver)
+ // TODO: This probably should be moved into the kolab_tags plugin
+ if ($this->tags || ($this->rc->plugins->get_plugin('kolab_tags')
+ && $this->rc->config->get('kolab_tags_driver') === 'annotate')
+ ) {
+ if (!$this->tags) {
+ $this->tags = new kolab_storage_tags();
+ }
+
+ // Remove tags that do not exist or create new ones
+ return $this->tags->compute($list, $create);
+ }
+
+ return array_values(array_filter(array_unique($list)));
+ }
+
+ /**
+ * Prepare the given note data for the plugin
+ */
+ protected function read_postprocess($note, $list_id)
+ {
+ $note['list'] = $list_id;
+
+ if (empty($note['changed']) && !empty($note['created'])) {
+ $note['changed'] = $note['created'];
+ }
+
+ // Remove tags that do not exist
+ $note['tags'] = $this->parse_categories($note['categories'] ?? [], false);
+
+ unset($note['categories']);
+
+ return $note;
+ }
+
+ /**
+ * Process the given note data (submitted by the client) before saving it
+ */
+ protected function write_preprocess($note, $old = [])
+ {
+ $object = $note;
+
+ // clean up HTML content
+ $object['description'] = $this->plugin->wash_html($object['description']);
+
+ // Add proper HTML header
+ $object['description'] = "<!DOCTYPE html>\n" . $object['description'];
+
+ // copy meta data (starting with _) from old object
+ foreach ((array) $old as $key => $val) {
+ if (!isset($object[$key]) && $key[0] == '_') {
+ $object[$key] = $val;
+ }
+ }
+
+ // convert link references into simple URIs
+ if (array_key_exists('links', $object)) {
+ $object['links'] = array_map(function ($link) { return is_array($link) ? $link['uri'] : strval($link); }, $object['links']);
+ } else {
+ if ($old) {
+ $object['links'] = $old['links'] ?? [];
+ }
+ }
+
+ // cleanup the tags list, create tags that does not exist yet
+ $object['categories'] = $this->parse_categories($object['tags'] ?? [], true);
+
+ unset($object['list'], $object['created'], $object['changed'], $object['tags']);
+
+ return $object;
+ }
+}
diff --git a/plugins/kolab_notes/kolab_notes.php b/plugins/kolab_notes/kolab_notes.php
--- a/plugins/kolab_notes/kolab_notes.php
+++ b/plugins/kolab_notes/kolab_notes.php
@@ -5,10 +5,10 @@
*
* Adds simple notes management features to the web client
*
- * @version @package_version@
* @author Thomas Bruederli <bruederli@kolabsys.com>
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
*
- * Copyright (C) 2014-2015, Kolab Systems AG <contact@kolabsys.com>
+ * Copyright (C) 2014-2025, Apheleia IT <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
@@ -29,23 +29,17 @@
public $task = '?(?!login|logout).*';
public $allowed_prefs = ['kolab_notes_sort_col'];
public $rc;
+ public $driver;
private $ui;
- private $lists;
- private $folders;
- private $cache = [];
private $message_notes = [];
private $note;
- private $bonnie_api = false;
- private $search_more_results = false;
/**
* Required startup method of a Roundcube plugin
*/
public function init()
{
- $this->require_plugin('libkolab');
-
$this->rc = rcube::get_instance();
// proceed initialization in startup hook
@@ -67,6 +61,13 @@
// load plugin configuration
$this->load_config();
+ $driver = $this->rc->config->get('kolab_notes_driver') ?: 'kolab';
+ $driver_class = "{$driver}_notes_driver";
+
+ require_once __DIR__ . "/drivers/{$driver}/{$driver_class}.php";
+
+ $this->driver = new $driver_class($this);
+
// load localizations
$this->add_texts('localization/', $args['task'] == 'notes' && (!$args['action'] || $args['action'] == 'dialog-ui'));
$this->rc->load_language($_SESSION['language'], ['notes.notes' => $this->gettext('navtitle')]); // add label for task title
@@ -121,12 +122,6 @@
if (!$this->rc->output->ajax_call && empty($this->rc->output->env['framed'])) {
$this->load_ui();
}
-
- // get configuration for the Bonnie API
- $this->bonnie_api = libkolab::get_bonnie_api();
-
- // notes use fully encoded identifiers
- kolab_storage::$encode_ids = true;
}
/**
@@ -150,249 +145,6 @@
}
}
- /**
- * Read available calendars for the current user and store them internally
- */
- private function _read_lists($force = false)
- {
- // already read sources
- if (isset($this->lists) && !$force) {
- return $this->lists;
- }
-
- // get all folders that have type "task"
- $folders = kolab_storage::sort_folders(kolab_storage::get_folders('note'));
- $this->lists = $this->folders = [];
-
- // find default folder
- $default_index = 0;
- foreach ($folders as $i => $folder) {
- if ($folder->default) {
- $default_index = $i;
- }
- }
-
- // put default folder on top of the list
- if ($default_index > 0) {
- $default_folder = $folders[$default_index];
- unset($folders[$default_index]);
- array_unshift($folders, $default_folder);
- }
-
- foreach ($folders as $folder) {
- $item = $this->folder_props($folder);
- $this->lists[$item['id']] = $item;
- $this->folders[$item['id']] = $folder;
- $this->folders[$folder->name] = $folder;
- }
- }
-
- /**
- * Get a list of available folders from this source
- */
- public function get_lists(&$tree = null)
- {
- $this->_read_lists();
-
- // attempt to create a default folder for this user
- if (empty($this->lists)) {
- $folder = ['name' => 'Notes', 'type' => 'note', 'default' => true, 'subscribed' => true];
- if (kolab_storage::folder_update($folder)) {
- $this->_read_lists(true);
- }
- }
-
- $folders = [];
- foreach ($this->lists as $id => $list) {
- if (!empty($this->folders[$id])) {
- $folders[] = $this->folders[$id];
- }
- }
-
- // include virtual folders for a full folder tree
- if (!is_null($tree)) {
- $folders = kolab_storage::folder_hierarchy($folders, $tree);
- }
-
- $delim = $this->rc->get_storage()->get_hierarchy_delimiter();
-
- $lists = [];
- foreach ($folders as $folder) {
- $list_id = $folder->id;
- $imap_path = explode($delim, $folder->name);
-
- // find parent
- do {
- array_pop($imap_path);
- $parent_id = kolab_storage::folder_id(implode($delim, $imap_path));
- } while (count($imap_path) > 1 && empty($this->folders[$parent_id]));
-
- // restore "real" parent ID
- if ($parent_id && empty($this->folders[$parent_id])) {
- $parent_id = kolab_storage::folder_id($folder->get_parent());
- }
-
- $fullname = $folder->get_name();
- $listname = $folder->get_foldername();
-
- // special handling for virtual folders
- if ($folder instanceof kolab_storage_folder_user) {
- $lists[$list_id] = [
- 'id' => $list_id,
- 'name' => $fullname,
- 'listname' => $listname,
- 'title' => $folder->get_title(),
- 'virtual' => true,
- 'editable' => false,
- 'rights' => 'l',
- 'group' => 'other virtual',
- 'class' => 'user',
- 'parent' => $parent_id,
- ];
- } elseif ($folder instanceof kolab_storage_folder_virtual) {
- $lists[$list_id] = [
- 'id' => $list_id,
- 'name' => $fullname,
- 'listname' => $listname,
- 'virtual' => true,
- 'editable' => false,
- 'rights' => 'l',
- 'group' => $folder->get_namespace(),
- 'parent' => $parent_id,
- ];
- } else {
- if (!$this->lists[$list_id]) {
- $this->lists[$list_id] = $this->folder_props($folder);
- $this->folders[$list_id] = $folder;
- }
- $this->lists[$list_id]['parent'] = $parent_id;
- $lists[$list_id] = $this->lists[$list_id];
- }
- }
-
- return $lists;
- }
-
- /**
- * Search for shared or otherwise not listed folders the user has access
- *
- * @param string $query Search string
- * @param string $source Section/source to search
- *
- * @return array List of notes folders
- */
- protected function search_lists($query, $source)
- {
- if (!kolab_storage::setup()) {
- return [];
- }
-
- $this->search_more_results = false;
- $this->lists = $this->folders = [];
-
- // find unsubscribed IMAP folders that have "event" type
- if ($source == 'folders') {
- foreach ((array)kolab_storage::search_folders('note', $query, ['other']) as $folder) {
- $this->folders[$folder->id] = $folder;
- $this->lists[$folder->id] = $this->folder_props($folder);
- }
- }
- // search other user's namespace via LDAP
- elseif ($source == 'users') {
- $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number
- foreach (kolab_storage::search_users($query, 0, [], $limit * 10) as $user) {
- $folders = [];
- // search for note folders shared by this user
- foreach (kolab_storage::list_user_folders($user, 'note', false) as $foldername) {
- $folders[] = new kolab_storage_folder($foldername, 'note');
- }
-
- $count = 0;
- if (count($folders)) {
- $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user);
- $this->folders[$userfolder->id] = $userfolder;
- $this->lists[$userfolder->id] = $this->folder_props($userfolder);
-
- foreach ($folders as $folder) {
- $this->folders[$folder->id] = $folder;
- $this->lists[$folder->id] = $this->folder_props($folder);
- $count++;
- }
- }
-
- if ($count >= $limit) {
- $this->search_more_results = true;
- break;
- }
- }
-
- }
-
- return $this->get_lists();
- }
-
- /**
- * Derive list properties from the given kolab_storage_folder object
- */
- protected function folder_props($folder)
- {
- if ($folder->get_namespace() == 'personal') {
- $norename = false;
- $editable = true;
- $rights = 'lrswikxtea';
- $alarms = true;
- } else {
- $alarms = false;
- $rights = 'lr';
- $editable = false;
- if (($myrights = $folder->get_myrights()) && !PEAR::isError($myrights)) {
- $rights = $myrights;
- if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) {
- $editable = strpos($rights, 'i');
- }
- }
- $info = $folder->get_folder_info();
- $norename = !$editable || $info['norename'] || $info['protected'];
- }
-
- $list_id = $folder->id;
- return [
- 'id' => $list_id,
- 'name' => $folder->get_name(),
- 'listname' => $folder->get_foldername(),
- 'editname' => $folder->get_foldername(),
- 'editable' => $editable,
- 'rights' => $rights,
- 'norename' => $norename,
- 'parentfolder' => $folder->get_parent(),
- 'subscribed' => (bool)$folder->is_subscribed(),
- 'default' => $folder->default,
- 'group' => $folder->default ? 'default' : $folder->get_namespace(),
- 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')),
- ];
- }
-
- /**
- * Get the kolab_calendar instance for the given calendar ID
- *
- * @param string $id List identifier (encoded imap folder name)
- *
- * @return ?kolab_storage_folder Object nor null if list doesn't exist
- */
- public function get_folder($id)
- {
- // create list and folder instance if necesary
- if (empty($this->lists[$id])) {
- $folder = kolab_storage::get_folder(kolab_storage::id_decode($id));
- if ($folder->type) {
- $this->folders[$id] = $folder;
- $this->lists[$id] = $this->folder_props($folder);
- }
- }
-
- return $this->folders[$id] ?? null;
- }
-
/******* UI functions ********/
/**
@@ -416,13 +168,11 @@
$storage = $this->rc->get_storage();
[$uid, $folder] = explode('-', $msgref, 2);
if ($message = $storage->get_message_headers($msgref)) {
+ $message->folder = $folder;
$this->rc->output->set_env('kolab_notes_template', [
'_from_mail' => true,
'title' => $message->get('subject'),
- 'links' => [kolab_storage_config::get_message_reference(
- kolab_storage_config::get_message_uri($message, $folder),
- 'note'
- )],
+ 'links' => [$this->driver->get_message_reference($message)],
]);
}
}
@@ -439,87 +189,29 @@
$search = rcube_utils::get_input_value('_q', rcube_utils::INPUT_GPC, true);
$list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC);
- $data = $this->notes_data($this->list_notes($list, $search), $tags);
+ $data = $this->driver->list_notes($list, $search);
+ $tags = [];
+
+ foreach ($data as $i => $note) {
+ unset($data[$i]['description']);
+ $tags = array_merge($tags, $note['tags'] ?? []);
+ $this->_client_encode($data[$i]);
+ }
$this->rc->output->command('plugin.data_ready', [
'list' => $list,
'search' => $search,
'data' => $data,
- 'tags' => array_values($tags),
+ 'tags' => array_values(array_unique($tags)),
]);
}
- /**
- * Convert the given note records for delivery to the client
- */
- protected function notes_data($records, &$tags)
- {
- $config = kolab_storage_config::get_instance();
- $tags = $config->apply_tags($records);
- $config->apply_links($records);
-
- foreach ($records as $i => $rec) {
- unset($records[$i]['description']);
- $this->_client_encode($records[$i]);
- }
-
- return $records;
- }
-
- /**
- * Read note records for the given list from the storage backend
- */
- protected function list_notes($list_id, $search = null)
- {
- $results = [];
-
- // query Kolab storage
- $query = [];
-
- // full text search (only works with cache enabled)
- if (strlen($search)) {
- $words = array_filter(rcube_utils::normalize_string(mb_strtolower($search), true));
- foreach ($words as $word) {
- if (strlen($word) > 2) { // only words > 3 chars are stored in DB
- $query[] = ['words', '~', $word];
- }
- }
- }
-
- $this->_read_lists();
- if ($folder = $this->get_folder($list_id)) {
- foreach ($folder->select($query, empty($query)) as $record) {
- // post-filter search results
- if (strlen($search)) {
- $matches = 0;
- $desc = $this->is_html($record) ? strip_tags($record['description']) : ($record['description'] ?? '');
- $contents = mb_strtolower($record['title'] . $desc);
-
- foreach ($words as $word) {
- if (mb_strpos($contents, $word) !== false) {
- $matches++;
- }
- }
-
- // skip records not matching all search words
- if ($matches < count($words)) {
- continue;
- }
- }
- $record['list'] = $list_id;
- $results[] = $record;
- }
- }
-
- return $results;
- }
-
/**
* Handler for delivering a full note record to the client
*/
public function note_record()
{
- $data = $this->get_note([
+ $data = $this->driver->get_note([
'uid' => rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC),
'list' => rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC),
]);
@@ -532,51 +224,6 @@
$this->rc->output->command('plugin.render_note', $data);
}
- /**
- * Get the full note record identified by the given UID + Lolder identifier
- */
- public function get_note($note)
- {
- if (is_array($note)) {
- $uid = $note['uid'] ?: $note['id'];
- $list_id = $note['list'];
- } else {
- $uid = $note;
- }
-
- // deliver from in-memory cache
- if (!empty($list_id) && !empty($this->cache["$list_id:$uid"])) {
- return $this->cache["$list_id:$uid"];
- }
-
- $result = false;
-
- $this->_read_lists();
- if (!empty($list_id)) {
- if ($folder = $this->get_folder($list_id)) {
- $result = $folder->get_object($uid);
- }
- }
- // iterate over all calendar folders and search for the event ID
- else {
- foreach ($this->folders as $list_id => $folder) {
- if ($result = $folder->get_object($uid)) {
- $result['list'] = $list_id;
- break;
- }
- }
- }
-
- if ($result) {
- // get note tags
- $result['tags'] = $this->get_tags($result['uid']);
- // get note links
- $result['links'] = $this->get_links($result['uid']);
- }
-
- return $result;
- }
-
/**
* Helper method to encode the given note record for use in the client
*/
@@ -588,22 +235,22 @@
}
}
- foreach (['created','changed'] as $key) {
- if (is_object($note[$key]) && $note[$key] instanceof DateTime) {
+ foreach (['created', 'changed'] as $key) {
+ if (!empty($note[$key]) && $note[$key] instanceof DateTime) {
$note[$key . '_'] = $note[$key]->format('U');
$note[$key] = $this->rc->format_date($note[$key]);
}
}
// clean HTML contents
- if (!empty($note['description']) && $this->is_html($note)) {
- $note['html'] = $this->_wash_html($note['description']);
+ if ($this->is_html($note)) {
+ $note['html'] = $this->wash_html($note['description']);
}
// convert link URIs references into structs
if (array_key_exists('links', $note)) {
foreach ((array)$note['links'] as $i => $link) {
- if (strpos($link, 'imap://') === 0 && ($msgref = kolab_storage_config::get_message_reference($link, 'note'))) {
+ if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link))) {
$note['links'][$i] = $msgref;
}
}
@@ -624,8 +271,18 @@
switch ($action) {
case 'new':
case 'edit':
- if ($success = $this->save_note($note)) {
- $refresh = $this->get_note($note);
+ $moved = true;
+ if (!empty($note['_fromlist'])) {
+ $list_id = $note['list'];
+ $note['list'] = $note['_fromlist'];
+ unset($note['_fromlist']);
+ $moved = $this->driver->move_note($note, $list_id);
+ $note['list'] = $list_id;
+ }
+ if ($moved) {
+ if ($success = $this->driver->save_note($note)) {
+ $refresh = $this->driver->get_note($note);
+ }
}
break;
@@ -633,8 +290,8 @@
$uids = explode(',', $note['uid']);
foreach ($uids as $uid) {
$note['uid'] = $uid;
- if (!($success = $this->move_note($note, $note['to']))) {
- $refresh = $this->get_note($note);
+ if (!($success = $this->driver->move_note($note, $note['to']))) {
+ $refresh = $this->driver->get_note($note);
break;
}
}
@@ -644,15 +301,15 @@
$uids = explode(',', $note['uid']);
foreach ($uids as $uid) {
$note['uid'] = $uid;
- if (!($success = $this->delete_note($note))) {
- $refresh = $this->get_note($note);
+ if (!($success = $this->driver->delete_note($note))) {
+ $refresh = $this->driver->get_note($note);
break;
}
}
break;
case 'changelog':
- $data = $this->get_changelog($note);
+ $data = $this->driver->get_changelog($note);
if (is_array($data) && !empty($data)) {
$rcmail = $this->rc;
$dtformat = $rcmail->config->get('date_format') . ' ' . $this->rc->config->get('time_format');
@@ -673,7 +330,7 @@
case 'diff':
$silent = true;
- $data = $this->get_diff($note, $note['rev1'], $note['rev2']);
+ $data = $this->driver->get_diff($note, $note['rev1'], $note['rev2']);
if (is_array($data)) {
$this->rc->output->command('plugin.note_show_diff', $data);
} else {
@@ -682,7 +339,7 @@
break;
case 'show':
- if ($rec = $this->get_revison($note, $note['rev'])) {
+ if ($rec = $this->driver->get_revison($note, $note['rev'])) {
$this->rc->output->command('plugin.note_show_revision', $this->_client_encode($rec));
} else {
$this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error');
@@ -691,8 +348,8 @@
break;
case 'restore':
- if ($this->restore_revision($note, $note['rev'])) {
- $refresh = $this->get_note($note);
+ if ($this->driver->restore_revision($note, $note['rev'])) {
+ $refresh = $this->driver->get_note($note);
$this->rc->output->command('display_message', $this->gettext(['name' => 'objectrestoresuccess', 'vars' => ['rev' => $note['rev']]]), 'confirmation');
$this->rc->output->command('plugin.close_history_dialog');
} else {
@@ -706,7 +363,10 @@
if ($success) {
$this->rc->output->show_message('successfullysaved', 'confirmation');
} elseif (!$silent) {
- $this->rc->output->show_message('errorsaving', 'error');
+ $last_error = $this->driver->last_error();
+ $error_msg = $this->gettext('errorsaving') . (!empty($last_error) ? ': ' . $last_error : '');
+
+ $this->rc->output->show_message($error_msg, 'error');
}
// unlock client
@@ -717,129 +377,6 @@
}
}
- /**
- * Update an note record with the given data
- *
- * @param array $note Hash array with note properties (id, list)
- *
- * @return bool True on success, False on error
- */
- private function save_note(&$note)
- {
- $this->_read_lists();
-
- $list_id = $note['list'];
- if (!$list_id || !($folder = $this->get_folder($list_id))) {
- return false;
- }
-
- // moved from another folder
- if (!empty($note['_fromlist']) && ($fromfolder = $this->get_folder($note['_fromlist']))) {
- if (!$fromfolder->move($note['uid'], $folder->name)) {
- return false;
- }
-
- unset($note['_fromlist']);
- }
-
- // load previous version of this record to merge
- $old = null;
- if (!empty($note['uid'])) {
- $old = $folder->get_object($note['uid']);
- if (!$old || PEAR::isError($old)) {
- return false;
- }
-
- // merge existing properties if the update isn't complete
- if (!isset($note['title']) || !isset($note['description'])) {
- $note += $old;
- }
- }
-
- // generate new note object from input
- $object = $this->_write_preprocess($note, $old);
-
- // email links and tags are handled separately
- $links = $object['links'] ?? null;
- $tags = $object['tags'] ?? null;
-
- unset($object['links']);
- unset($object['tags']);
-
- $saved = $folder->save($object, 'note', $note['uid']);
-
- if (!$saved) {
- rcube::raise_error(
- [
- 'code' => 600, 'type' => 'php',
- 'file' => __FILE__, 'line' => __LINE__,
- 'message' => "Error saving note object to Kolab server"],
- true,
- false
- );
- $saved = false;
- } else {
- // save links in configuration.relation object
- $this->save_links($object['uid'], $links);
- // save tags in configuration.relation object
- $this->save_tags($object['uid'], $tags);
-
- $note = $object;
- $note['list'] = $list_id;
- $note['tags'] = (array) $tags;
-
- // cache this in memory for later read
- $key = $list_id . ':' . $note['uid'];
- $this->cache[$key] = $note;
- }
-
- return $saved;
- }
-
- /**
- * Move the given note to another folder
- */
- public function move_note($note, $list_id)
- {
- $this->_read_lists();
-
- $tofolder = $this->get_folder($list_id);
- $fromfolder = $this->get_folder($note['list']);
-
- if ($fromfolder && $tofolder) {
- return $fromfolder->move($note['uid'], $tofolder->name);
- }
-
- return false;
- }
-
- /**
- * Remove a single note record from the backend
- *
- * @param array $note Hash array with note properties (id, list)
- * @param bool $force Remove record irreversible (mark as deleted otherwise)
- *
- * @return bool True on success, False on error
- */
- public function delete_note($note, $force = true)
- {
- $this->_read_lists();
-
- $list_id = $note['list'];
- if (!$list_id || !($folder = $this->get_folder($list_id))) {
- return false;
- }
-
- $status = $folder->delete($note['uid'], $force);
-
- if ($status) {
- $this->save_links($note['uid'], null);
- $this->save_tags($note['uid'], null);
- }
-
- return $status;
- }
-
/**
* Render the template for printing with placeholders
*/
@@ -848,7 +385,7 @@
$uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_GET);
$list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GET);
- $this->note = $this->get_note(['uid' => $uid, 'list' => $list]);
+ $this->note = $this->driver->get_note(['uid' => $uid, 'list' => $list]);
// encode for client use
if (is_array($this->note)) {
@@ -887,193 +424,6 @@
return $this->note['html'] ?? rcube::Q($this->note['description']);
}
- /**
- * Provide a list of revisions for the given object
- *
- * @param array $note Hash array with note properties
- *
- * @return array|false List of changes, each as a hash array
- */
- public function get_changelog($note)
- {
- if (empty($this->bonnie_api)) {
- return false;
- }
-
- [$uid, $mailbox, $msguid] = $this->_resolve_note_identity($note);
-
- $result = $uid && $mailbox ? $this->bonnie_api->changelog('note', $uid, $mailbox, $msguid) : null;
- if (is_array($result) && $result['uid'] == $uid) {
- return $result['changes'];
- }
-
- return false;
- }
-
- /**
- * Return full data of a specific revision of a note record
- *
- * @param mixed $note UID string or hash array with note properties
- * @param mixed $rev Revision number
- *
- * @return array|false Note object as hash array
- */
- public function get_revison($note, $rev)
- {
- if (empty($this->bonnie_api)) {
- return false;
- }
-
- [$uid, $mailbox, $msguid] = $this->_resolve_note_identity($note);
-
- // call Bonnie API
- $result = $this->bonnie_api->get('note', $uid, $rev, $mailbox, $msguid);
- if (is_array($result) && $result['uid'] == $uid && !empty($result['xml'])) {
- $format = kolab_format::factory('note');
- $format->load($result['xml']);
- $rec = $format->to_array();
-
- if ($format->is_valid()) {
- $rec['rev'] = $result['rev'];
- return $rec;
- }
- }
-
- return false;
- }
-
- /**
- * Get a list of property changes beteen two revisions of a note object
- *
- * @param array $note Hash array with note properties
- * @param mixed $rev1 Revision: "from"
- * @param mixed $rev2 Revision: "to"
- *
- * @return array|false List of property changes, each as a hash array
- */
- public function get_diff($note, $rev1, $rev2)
- {
- if (empty($this->bonnie_api)) {
- return false;
- }
-
- [$uid, $mailbox, $msguid] = $this->_resolve_note_identity($note);
-
- // call Bonnie API
- $result = $this->bonnie_api->diff('note', $uid, $rev1, $rev2, $mailbox, $msguid);
- if (is_array($result) && $result['uid'] == $uid) {
- $result['rev1'] = $rev1;
- $result['rev2'] = $rev2;
-
- // convert some properties, similar to self::_client_encode()
- $keymap = [
- 'summary' => 'title',
- 'lastmodified-date' => 'changed',
- ];
-
- // map kolab object properties to keys and values the client expects
- array_walk($result['changes'], function (&$change, $i) use ($keymap) {
- if (array_key_exists($change['property'], $keymap)) {
- $change['property'] = $keymap[$change['property']];
- }
-
- if ($change['property'] == 'created' || $change['property'] == 'changed') {
- if ($old_ = rcube_utils::anytodatetime($change['old'])) {
- $change['old_'] = $this->rc->format_date($old_);
- }
- if ($new_ = rcube_utils::anytodatetime($change['new'])) {
- $change['new_'] = $this->rc->format_date($new_);
- }
- }
-
- // compute a nice diff of note contents
- if ($change['property'] == 'description') {
- $change['diff_'] = libkolab::html_diff($change['old'], $change['new']);
- if (!empty($change['diff_'])) {
- unset($change['old'], $change['new']);
- $change['diff_'] = preg_replace(['!^.*<body[^>]*>!Uims','!</body>.*$!Uims'], '', $change['diff_']);
- $change['diff_'] = preg_replace("!</(p|li|span)>\n!", '</\\1>', $change['diff_']);
- }
- }
- });
-
- return $result;
- }
-
- return false;
- }
-
- /**
- * Command the backend to restore a certain revision of a note.
- * This shall replace the current object with an older version.
- *
- * @param array $note Hash array with note properties (id, list)
- * @param mixed $rev Revision number
- *
- * @return bool True on success, False on failure
- */
- public function restore_revision($note, $rev)
- {
- if (empty($this->bonnie_api)) {
- return false;
- }
-
- [$uid, $mailbox, $msguid] = $this->_resolve_note_identity($note);
-
- $folder = $this->get_folder($note['list']);
- $success = false;
-
- if ($folder && ($raw_msg = $this->bonnie_api->rawdata('note', $uid, $rev, $mailbox))) {
- $imap = $this->rc->get_storage();
-
- // insert $raw_msg as new message
- if ($imap->save_message($folder->name, $raw_msg, null, false)) {
- $success = true;
-
- // delete old revision from imap and cache
- $imap->delete_message($msguid, $folder->name);
- $folder->cache->set($msguid, false);
- $this->cache = [];
- }
- }
-
- return $success;
- }
-
- /**
- * Helper method to resolved the given note identifier into uid and mailbox
- *
- * @return array (uid,mailbox,msguid) tuple
- */
- private function _resolve_note_identity($note)
- {
- $mailbox = $msguid = null;
-
- if (!is_array($note)) {
- $note = $this->get_note($note);
- }
-
- if (is_array($note)) {
- $uid = $note['uid'] ?: $note['id'];
- $list = $note['list'];
- } else {
- return [null, $mailbox, $msguid];
- }
-
- if ($folder = $this->get_folder($list)) {
- $mailbox = $folder->get_mailbox_id();
-
- // get object from storage in order to get the real object uid an msguid
- if ($rec = $folder->get_object($uid)) {
- $msguid = $rec['_msguid'];
- $uid = $rec['uid'];
- }
- }
-
- return [$uid, $mailbox, $msguid];
- }
-
-
/**
* Handler for client requests to list (aka folder) actions
*/
@@ -1091,66 +441,49 @@
switch ($action) {
case 'form-new':
case 'form-edit':
- $this->_read_lists();
- $this->ui->list_editform($action, $this->lists[$list['id']], $this->folders[$list['id']]);
+ $this->ui->list_editform($action, $list['id']);
exit;
case 'new':
- $list['type'] = 'note';
$list['subscribed'] = true;
- $folder = kolab_storage::folder_update($list);
- if ($folder === false) {
- $save_error = $this->gettext(kolab_storage::$last_error);
- } else {
- $success = true;
+ if ($success = $this->driver->list_create($list)) {
$update_cmd = 'plugin.update_list';
- $list['id'] = kolab_storage::folder_id($folder);
$list['_reload'] = true;
}
break;
case 'edit':
- $this->_read_lists();
- $oldparent = $this->lists[$list['id']]['parentfolder'];
- $newfolder = kolab_storage::folder_update($list);
+ $oldid = $list['id'];
+ $oldparent = $this->driver->list_get($list['id'])['parent'] ?? null;
- if ($newfolder === false) {
- $save_error = $this->gettext(kolab_storage::$last_error);
- } else {
- $success = true;
+ if ($success = $this->driver->list_update($list)) {
$update_cmd = 'plugin.update_list';
- $list['newid'] = kolab_storage::folder_id($newfolder);
$list['_reload'] = $list['parent'] != $oldparent;
- // compose the new display name
- $delim = $this->rc->get_storage()->get_hierarchy_delimiter();
- $path_imap = explode($delim, $newfolder);
- $list['name'] = kolab_storage::object_name($newfolder);
- $list['editname'] = rcube_charset::convert(array_pop($path_imap), 'UTF7-IMAP');
- $list['listname'] = $list['editname'];
+ if ($oldid != $list['id']) {
+ $list['newid'] = $list['id'];
+ $list['id'] = $oldid;
+ }
}
break;
case 'delete':
- $this->_read_lists();
- $folder = $this->get_folder($list['id']);
- if ($folder && kolab_storage::folder_delete($folder->name)) {
- $success = true;
+ if ($success = $this->driver->list_delete($list)) {
$update_cmd = 'plugin.destroy_list';
- } else {
- $save_error = $this->gettext(kolab_storage::$last_error);
}
break;
case 'search':
$this->load_ui();
+ $q = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
+ $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
$results = [];
- foreach ((array)$this->search_lists(rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC)) as $id => $prop) {
+ foreach ((array)$this->driver->search_lists($q, $source) as $id => $prop) {
$editname = $prop['editname'];
unset($prop['editname']); // force full name to be displayed
- // let the UI generate HTML and CSS representation for this calendar
+ // let the UI generate HTML and CSS representation for this folder
$html = $this->ui->folder_list_item($id, $prop, $jsenv, true);
$prop += $jsenv[$id] ?? []; // @phpstan-ignore-line
$prop['editname'] = $editname;
@@ -1158,8 +491,8 @@
$results[] = $prop;
}
- // report more results available
- if ($this->search_more_results) {
+
+ if ($this->driver->has_more) {
$this->rc->output->show_message('autocompletemore', 'notice');
}
@@ -1167,27 +500,7 @@
return;
case 'subscribe':
- $success = false;
- if ($list['id'] && ($folder = $this->get_folder($list['id']))) {
- if (isset($list['permanent'])) {
- $success |= $folder->subscribe(intval($list['permanent']));
- }
- if (isset($list['active'])) {
- $success |= $folder->activate(intval($list['active']));
- }
-
- // apply to child folders, too
- if ($list['recursive']) {
- foreach ((array)kolab_storage::list_folders($folder->name, '*', 'node') as $subfolder) {
- if (isset($list['permanent'])) {
- ($list['permanent'] ? kolab_storage::folder_subscribe($subfolder) : kolab_storage::folder_unsubscribe($subfolder));
- }
- if (isset($list['active'])) {
- ($list['active'] ? kolab_storage::folder_activate($subfolder) : kolab_storage::folder_deactivate($subfolder));
- }
- }
- }
- }
+ $success = $this->driver->list_subscribe($list);
break;
}
@@ -1200,7 +513,9 @@
$this->rc->output->command($update_cmd, $list);
}
} else {
- $error_msg = $this->gettext('errorsaving') . (!empty($save_error) ? ': ' . $save_error : '');
+ $last_error = $this->driver->last_error();
+ $error_msg = $this->gettext('errorsaving') . (!empty($last_error) ? ': ' . $last_error : '');
+
$this->rc->output->show_message($error_msg, 'error');
}
}
@@ -1216,7 +531,7 @@
$list = $args['param']['notes_list'];
foreach ($uids as $uid) {
- if ($note = $this->get_note(['uid' => $uid, 'list' => $list])) {
+ if ($note = $this->driver->get_note(['uid' => $uid, 'list' => $list])) {
$data = $this->note2message($note);
$args['attachments'][] = [
'name' => abbreviate_string($note['title'], 50, ''),
@@ -1243,7 +558,8 @@
public function mail_message_load($p)
{
if (empty($p['object']->headers->others['x-kolab-type'])) {
- $this->message_notes = $this->get_message_notes($p['object']->headers, $p['object']->folder);
+ $p['object']->headers->folder = $p['object']->folder;
+ $this->message_notes = $this->driver->get_message_notes($p['object']->headers);
}
}
@@ -1274,7 +590,7 @@
/**
* Determine whether the given note is HTML formatted
*/
- private function is_html($note)
+ public static function is_html($note)
{
// check for opening and closing <html> or <body> tags
return !empty($note['description'])
@@ -1320,117 +636,10 @@
return $message->getMessage();
}
- private function save_links($uid, $links)
- {
- $config = kolab_storage_config::get_instance();
- return $config->save_object_links($uid, (array) $links);
- }
-
- /**
- * Find messages assigned to specified note
- */
- private function get_links($uid)
- {
- $config = kolab_storage_config::get_instance();
- return $config->get_object_links($uid);
- }
-
- /**
- * Get note tags
- */
- private function get_tags($uid)
- {
- $config = kolab_storage_config::get_instance();
- $tags = $config->get_tags($uid);
- $tags = array_map(function ($v) { return $v['name']; }, $tags);
-
- return $tags;
- }
-
- /**
- * Find notes assigned to specified message
- */
- private function get_message_notes($message, $folder)
- {
- $config = kolab_storage_config::get_instance();
- $result = $config->get_message_relations($message, $folder, 'note');
-
- foreach ($result as $idx => $note) {
- $result[$idx]['list'] = kolab_storage::folder_id($note['_mailbox']);
- }
-
- return $result;
- }
-
- /**
- * Update note tags
- */
- private function save_tags($uid, $tags)
- {
- $config = kolab_storage_config::get_instance();
- $config->save_tags($uid, $tags);
- }
-
- /**
- * Process the given note data (submitted by the client) before saving it
- */
- private function _write_preprocess($note, $old = [])
- {
- $object = $note;
-
- // TODO: handle attachments
-
- // convert link references into simple URIs
- if (array_key_exists('links', $note)) {
- $object['links'] = array_map(function ($link) { return is_array($link) ? $link['uri'] : strval($link); }, $note['links']);
- } else {
- if ($old) {
- $object['links'] = $old['links'] ?? null;
- }
- }
-
- // clean up HTML content
- $object['description'] = $this->_wash_html($note['description']);
- $is_html = true;
-
- // try to be smart and convert to plain-text if no real formatting is detected
- if (preg_match('!<body><(?:p|pre)>(.*)</(?:p|pre)></body>!Uims', $object['description'], $m)) {
- if (!preg_match('!<(a|b|i|strong|em|p|span|div|pre|li|img)(\s+[a-z]|>)!im', $m[1], $n)
- || ($n[1] != 'img' && !strpos($m[1], '</' . $n[1] . '>'))
- ) {
- // $converter = new rcube_html2text($m[1], false, true, 0);
- // $object['description'] = rtrim($converter->get_text());
- $object['description'] = html_entity_decode(preg_replace('!<br(\s+/)>!', "\n", $m[1]));
- $is_html = false;
- }
- }
-
- // Add proper HTML header, otherwise Kontact renders it as plain text
- if ($is_html) {
- $object['description'] = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">' . "\n" .
- str_replace('<head>', '<head><meta name="qrichtext" content="1" />', $object['description']);
- }
-
- // copy meta data (starting with _) from old object
- foreach ((array)$old as $key => $val) {
- if (!isset($object[$key]) && $key[0] == '_') {
- $object[$key] = $val;
- }
- }
-
- // make list of categories unique
- if (!empty($object['tags'])) {
- $object['tags'] = array_unique(array_filter($object['tags']));
- }
-
- unset($object['list'], $object['tempid'], $object['created'], $object['changed'], $object['created_'], $object['changed_']);
- return $object;
- }
-
/**
* Sanity checks/cleanups HTML content
*/
- private function _wash_html($html)
+ public function wash_html($html)
{
// Add header with charset spec., washtml cannot work without that
$html = '<html><head>'
@@ -1493,5 +702,4 @@
return $out;
}
-
}
diff --git a/plugins/kolab_notes/kolab_notes_ui.php b/plugins/kolab_notes/kolab_notes_ui.php
--- a/plugins/kolab_notes/kolab_notes_ui.php
+++ b/plugins/kolab_notes/kolab_notes_ui.php
@@ -2,7 +2,6 @@
class kolab_notes_ui
{
- private $folder;
private $rc;
private $plugin;
private $list;
@@ -39,8 +38,8 @@
}
/**
- * Register handler methods for the template engine
- */
+ * Register handler methods for the template engine
+ */
public function init_templates()
{
$this->plugin->register_handler('plugin.notebooks', [$this, 'folders']);
@@ -61,6 +60,8 @@
// load config options and user prefs relevant for the UI
$settings = [
'sort_col' => $this->rc->config->get('kolab_notes_sort_col', 'changed'),
+ 'subscriptions' => $this->plugin->driver->subscriptions,
+
];
if ($list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC)) {
@@ -87,7 +88,7 @@
}
$tree = !$is_select ? true : null;
- $lists = $this->plugin->get_lists($tree);
+ $lists = $this->plugin->driver->get_lists($tree);
$jsenv = [];
// @phpstan-ignore-next-line
@@ -175,7 +176,7 @@
} elseif (!$prop['editable']) {
$classes[] = 'readonly';
}
- if ($prop['subscribed']) {
+ if (!empty($prop['subscribed'])) {
$classes[] = 'subscribed';
}
if ($prop['class']) {
@@ -298,10 +299,9 @@
/**
* Render create/edit form for notes lists (folders)
*/
- public function list_editform($action, $list, $folder)
+ public function list_editform($action, $list)
{
- $this->list = $list;
- $this->folder = is_object($folder) ? $folder->name : ''; // UTF7;
+ $this->list = $list;
$this->rc->output->set_env('pagetitle', $this->plugin->gettext('arialabelnotebookform'));
$this->rc->output->add_handler('folderform', [$this, 'notebookform']);
@@ -313,51 +313,7 @@
*/
public function notebookform($attrib)
{
- $folder_name = $this->folder;
- $hidden_fields[] = ['name' => 'oldname', 'value' => $folder_name];
-
- $storage = $this->rc->get_storage();
- $delim = $storage->get_hierarchy_delimiter();
- $form = [];
-
- if (strlen($folder_name)) {
- $options = $storage->folder_info($folder_name);
-
- $path_imap = explode($delim, $folder_name);
- array_pop($path_imap); // pop off name part
- $path_imap = implode($delim, $path_imap);
- } else {
- $path_imap = '';
- $options = [];
- }
-
- // General tab
- $form['properties'] = [
- 'name' => $this->rc->gettext('properties'),
- 'fields' => [],
- ];
-
- // folder name (default field)
- $input_name = new html_inputfield(['name' => 'name', 'id' => 'noteslist-name', 'size' => 20]);
- $form['properties']['fields']['name'] = [
- 'label' => $this->plugin->gettext('listname'),
- 'value' => $input_name->show($this->list['editname'], ['disabled' => ($options['norename'] || $options['protected'])]),
- 'id' => 'noteslist-name',
- ];
-
- // prevent user from moving folder
- if (!empty($options) && ($options['norename'] || $options['protected'])) {
- $hidden_fields[] = ['name' => 'parent', 'value' => $path_imap];
- } else {
- $select = kolab_storage::folder_selector('note', ['name' => 'parent', 'id' => 'parent-folder'], $folder_name);
- $form['properties']['fields']['path'] = [
- 'label' => $this->plugin->gettext('parentfolder'),
- 'value' => $select->show(strlen($folder_name) ? $path_imap : ''),
- 'id' => 'parent-folder',
- ];
- }
-
- $form_html = kolab_utils::folder_form($form, $folder_name, 'kolab_notes', $hidden_fields);
+ $form_html = $this->plugin->driver->list_form($this->list);
return html::tag('form', $attrib + ['action' => '#', 'method' => 'post', 'id' => 'noteslistpropform'], $form_html);
}
diff --git a/plugins/kolab_notes/localization/en_US.inc b/plugins/kolab_notes/localization/en_US.inc
--- a/plugins/kolab_notes/localization/en_US.inc
+++ b/plugins/kolab_notes/localization/en_US.inc
@@ -28,7 +28,7 @@
$labels['newnotebook'] = 'Create a new notebook';
$labels['addnotebook'] = 'Add notebook';
$labels['deletelist'] = 'Delete notebook';
-$labels['editlist'] = 'Edit/Share notebook';
+$labels['editlist'] = 'Edit notebook';
$labels['listname'] = 'Name';
$labels['tabsharing'] = 'Sharing';
$labels['discard'] = 'Discard';
diff --git a/plugins/kolab_notes/notes.js b/plugins/kolab_notes/notes.js
--- a/plugins/kolab_notes/notes.js
+++ b/plugins/kolab_notes/notes.js
@@ -518,6 +518,19 @@
notebookslist.select(prop.newid);
}
}
+ else if (prop.listname != me.notebooks[prop.id].listname) {
+ var book = $.extend({}, me.notebooks[prop.id]);
+ book.name = prop.name;
+ book.listname = prop.listname;
+ book.editname = prop.editname || prop.name;
+
+ me.notebooks[prop.id] = book;
+
+ // update treelist item
+ var li = $(notebookslist.get_item(prop.id));
+ $('.listname', li).html(prop.listname);
+ notebookslist.update(prop.id, { id: book.id, html: li.html() });
+ }
}
/**
diff --git a/plugins/kolab_notes/skins/elastic/templates/notes.html b/plugins/kolab_notes/skins/elastic/templates/notes.html
--- a/plugins/kolab_notes/skins/elastic/templates/notes.html
+++ b/plugins/kolab_notes/skins/elastic/templates/notes.html
@@ -117,8 +117,12 @@
<roundcube:button type="link-menuitem" command="list-create" label="kolab_notes.addnotebook" class="create disabled" classAct="create active" />
<roundcube:button type="link-menuitem" command="list-edit" label="kolab_notes.editlist" class="edit disabled" classAct="edit active" />
<roundcube:button type="link-menuitem" command="list-delete" label="kolab_notes.deletelist" class="delete disabled" classAct="delete active" />
- <roundcube:button type="link-menuitem" command="list-remove" label="kolab_notes.removelist" class="remove disabled" classAct="remove active" />
- <roundcube:button type="link-menuitem" command="folders" task="settings" label="managefolders" class="folders disabled" classAct="folders active" />
+ <roundcube:if condition="env:kolab_notes_settings['subscriptions']" />
+ <roundcube:button type="link-menuitem" command="list-remove" label="kolab_notes.removelist" class="remove disabled" classAct="remove active" />
+ <roundcube:endif />
+ <roundcube:if condition="config:kolab_notes_driver == 'kolab'" />
+ <roundcube:button type="link-menuitem" command="folders" task="settings" label="managefolders" class="folders disabled" classAct="folders active" />
+ <roundcube:endif />
</ul>
</div>
diff --git a/plugins/kolab_notes/skins/larry/templates/notes.html b/plugins/kolab_notes/skins/larry/templates/notes.html
--- a/plugins/kolab_notes/skins/larry/templates/notes.html
+++ b/plugins/kolab_notes/skins/larry/templates/notes.html
@@ -56,8 +56,12 @@
<ul class="toolbarmenu" id="notesoptionsmenu-menu" role="menu" aria-labelledby="aria-label-optionsmenu">
<li role="menuitem"><roundcube:button type="link" command="list-edit" label="edit" classAct="active" /></li>
<li role="menuitem"><roundcube:button type="link" command="list-delete" label="delete" classAct="active" /></li>
- <li role="menuitem"><roundcube:button type="link" command="list-remove" label="kolab_notes.removelist" classAct="active" /></li>
- <li role="menuitem"><roundcube:button type="link" command="folders" task="settings" label="managefolders" classAct="active" /></li>
+ <roundcube:if condition="env:kolab_notes_settings['subscriptions']" />
+ <li role="menuitem"><roundcube:button type="link" command="list-remove" label="kolab_notes.removelist" classAct="active" /></li>
+ <roundcube:endif />
+ <roundcube:if condition="config:kolab_notes_driver == 'kolab'" />
+ <li role="menuitem"><roundcube:button type="link" command="folders" task="settings" label="managefolders" classAct="active" /></li>
+ <roundcube:endif />
</ul>
</div>
</div>
diff --git a/plugins/libkolab/SQL/mysql.initial.sql b/plugins/libkolab/SQL/mysql.initial.sql
--- a/plugins/libkolab/SQL/mysql.initial.sql
+++ b/plugins/libkolab/SQL/mysql.initial.sql
@@ -227,6 +227,22 @@
PRIMARY KEY(`folder_id`,`uid`)
) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+DROP TABLE IF EXISTS `kolab_cache_dav_note`;
+
+CREATE TABLE `kolab_cache_dav_note` (
+ `folder_id` BIGINT UNSIGNED NOT NULL,
+ `uid` VARCHAR(512) NOT NULL,
+ `etag` VARCHAR(128) DEFAULT NULL,
+ `created` DATETIME DEFAULT NULL,
+ `changed` DATETIME DEFAULT NULL,
+ `data` LONGTEXT NOT NULL,
+ `tags` TEXT NOT NULL,
+ `words` TEXT NOT NULL,
+ CONSTRAINT `fk_kolab_cache_dav_note_folder` FOREIGN KEY (`folder_id`)
+ REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ PRIMARY KEY(`folder_id`,`uid`)
+) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
SET FOREIGN_KEY_CHECKS=1;
-REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2023111200');
+REPLACE INTO `system` (`name`, `value`) VALUES ('libkolab-version', '2026013000');
diff --git a/plugins/libkolab/SQL/mysql/2026013000.sql b/plugins/libkolab/SQL/mysql/2026013000.sql
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/SQL/mysql/2026013000.sql
@@ -0,0 +1,15 @@
+DROP TABLE IF EXISTS `kolab_cache_dav_note`;
+
+CREATE TABLE `kolab_cache_dav_note` (
+ `folder_id` BIGINT UNSIGNED NOT NULL,
+ `uid` VARCHAR(512) NOT NULL,
+ `etag` VARCHAR(128) DEFAULT NULL,
+ `created` DATETIME DEFAULT NULL,
+ `changed` DATETIME DEFAULT NULL,
+ `data` LONGTEXT NOT NULL,
+ `tags` TEXT NOT NULL,
+ `words` TEXT NOT NULL,
+ CONSTRAINT `fk_kolab_cache_dav_note_folder` FOREIGN KEY (`folder_id`)
+ REFERENCES `kolab_folders`(`folder_id`) ON DELETE CASCADE ON UPDATE CASCADE,
+ PRIMARY KEY(`folder_id`,`uid`)
+) ROW_FORMAT=DYNAMIC ENGINE=INNODB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
diff --git a/plugins/libkolab/SQL/postgres.initial.sql b/plugins/libkolab/SQL/postgres.initial.sql
--- a/plugins/libkolab/SQL/postgres.initial.sql
+++ b/plugins/libkolab/SQL/postgres.initial.sql
@@ -203,4 +203,17 @@
PRIMARY KEY(folder_id, uid)
);
-INSERT INTO "system" (name, "value") VALUES ('libkolab-version', '2023111200');
+CREATE TABLE kolab_cache_dav_note (
+ folder_id integer NOT NULL
+ REFERENCES kolab_folders (folder_id) ON DELETE CASCADE ON UPDATE CASCADE,
+ uid varchar(512) NOT NULL,
+ etag varchar(128) NOT NULL,
+ created timestamp with time zone DEFAULT NULL,
+ changed timestamp with time zone DEFAULT NULL,
+ data text NOT NULL,
+ tags text NOT NULL,
+ words text NOT NULL,
+ PRIMARY KEY(folder_id, uid)
+);
+
+INSERT INTO "system" (name, "value") VALUES ('libkolab-version', '2026013000');
diff --git a/plugins/libkolab/SQL/postgres/2026013000.sql b/plugins/libkolab/SQL/postgres/2026013000.sql
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/SQL/postgres/2026013000.sql
@@ -0,0 +1,12 @@
+CREATE TABLE kolab_cache_dav_note (
+ folder_id integer NOT NULL
+ REFERENCES kolab_folders (folder_id) ON DELETE CASCADE ON UPDATE CASCADE,
+ uid varchar(512) NOT NULL,
+ etag varchar(128) NOT NULL,
+ created timestamp with time zone DEFAULT NULL,
+ changed timestamp with time zone DEFAULT NULL,
+ data text NOT NULL,
+ tags text NOT NULL,
+ words text NOT NULL,
+ PRIMARY KEY(folder_id, uid)
+);
diff --git a/plugins/libkolab/SQL/sqlite.initial.sql b/plugins/libkolab/SQL/sqlite.initial.sql
--- a/plugins/libkolab/SQL/sqlite.initial.sql
+++ b/plugins/libkolab/SQL/sqlite.initial.sql
@@ -186,4 +186,16 @@
PRIMARY KEY(folder_id, uid)
);
-INSERT INTO system (name, value) VALUES ('libkolab-version', '2023111200');
+CREATE TABLE kolab_cache_dav_note (
+ folder_id INTEGER NOT NULL,
+ uid VARCHAR(512) NOT NULL,
+ etag VARCHAR(128) NOT NULL,
+ created DATETIME DEFAULT NULL,
+ changed DATETIME DEFAULT NULL,
+ data TEXT NOT NULL,
+ tags TEXT NOT NULL,
+ words TEXT NOT NULL,
+ PRIMARY KEY(folder_id, uid)
+);
+
+INSERT INTO system (name, value) VALUES ('libkolab-version', '2026013000');
diff --git a/plugins/libkolab/SQL/sqlite/2026013000.sql b/plugins/libkolab/SQL/sqlite/2026013000.sql
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/SQL/sqlite/2026013000.sql
@@ -0,0 +1,11 @@
+CREATE TABLE kolab_cache_dav_note (
+ folder_id INTEGER NOT NULL,
+ uid VARCHAR(512) NOT NULL,
+ etag VARCHAR(128) NOT NULL,
+ created DATETIME DEFAULT NULL,
+ changed DATETIME DEFAULT NULL,
+ data TEXT NOT NULL,
+ tags TEXT NOT NULL,
+ words TEXT NOT NULL,
+ PRIMARY KEY(folder_id, uid)
+);
diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php
--- a/plugins/libkolab/lib/kolab_dav_client.php
+++ b/plugins/libkolab/lib/kolab_dav_client.php
@@ -31,6 +31,8 @@
public const INVITE_ACCEPTED = 'accepted';
public const INVITE_DECLINED = 'declined';
+ public const KOLAB_NS = 'Kolab:';
+
public const NOTIFICATION_SHARE_INVITE = 'share-invite-notification';
public const NOTIFICATION_SHARE_REPLY = 'share-reply-notification';
@@ -74,7 +76,7 @@
/**
* Execute HTTP request to a DAV server
*/
- protected function request($path, $method, $body = '', $headers = [])
+ protected function request($path, $method, $body = '', $headers = [], $noxml = false)
{
$rcube = rcube::get_instance();
$debug = (bool) $rcube->config->get('dav_debug');
@@ -117,12 +119,17 @@
}
if ($code >= 300) {
- throw new Exception("DAV Error ($code):\n{$body}");
+ if (stripos($body, '<html') !== false) {
+ // Cut HTML response, it is usually useless
+ $body = '[html code removed]';
+ }
+
+ throw new Exception("DAV Error for {$path} ($code):\n{$body}");
}
$this->responseHeaders = $response->getHeader();
- return $this->parseXML($body);
+ return $noxml ? $body : $this->parseXML($body);
} catch (Exception $e) {
rcube::raise_error($e, true, false);
return false;
@@ -179,7 +186,7 @@
. '</d:prop>'
. '</d:propfind>';
- $response = $this->request($principal_href, 'PROPFIND', $body);
+ $response = $this->request($principal_href, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']);
if (empty($response)) {
return false;
@@ -215,9 +222,14 @@
*/
public function getHome($type)
{
- // FIXME: Can this be discovered?
- if ($type == 'PRINCIPAL') {
- $path = '/principals/user/';
+ // FIXME: Can these be discovered?
+ $paths = [
+ 'PRINCIPAL' => '/principals/user/',
+ 'NOTE' => '/files/user/' . $this->user,
+ ];
+
+ if (isset($paths[$type])) {
+ $path = $paths[$type];
if ($this->path) {
$path = '/' . trim($this->path, '/') . $path;
}
@@ -244,12 +256,17 @@
/**
* Get list of folders of specified type.
*
- * @param string $component Component to filter by (VEVENT, VTODO, VCARD)
+ * @param string $component Component to filter by (VEVENT, VTODO, VCARD, NOTE)
*
* @return false|array List of folders' metadata or False on error
*/
public function listFolders($component = 'VEVENT')
{
+ // TODO: For Notes, getting a list of folders might get slow if user has many folder/files
+ // in his WebDAV root. We should consider implementing some way to filter the list server-side.
+ // We might partially implement SEARCH request. FOr example:
+ // https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/search.html
+
$root_href = $this->getHome($component);
if ($root_href === null) {
@@ -294,6 +311,10 @@
if (in_array('addressbook', $folder['resource_type'])) {
$folders[] = $folder;
}
+ } elseif ($component == 'NOTE') {
+ if (in_array('notebook', $folder['resource_type'])) {
+ $folders[] = $folder;
+ }
} elseif (in_array('calendar', $folder['resource_type']) && in_array($component, (array) $folder['types'])) {
$folders[] = $folder;
}
@@ -307,7 +328,7 @@
*
* @param string $location Object location
* @param string $content Object content
- * @param string $component Content type (VEVENT, VTODO, VCARD)
+ * @param string $component Content type (VEVENT, VTODO, VCARD, NOTE)
*
* @return false|string|null ETag string (or NULL) on success, False on error
*/
@@ -317,6 +338,7 @@
'VEVENT' => 'text/calendar',
'VTODO' => 'text/calendar',
'VCARD' => 'text/vcard',
+ 'NOTE' => 'text/html',
];
$headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8'];
@@ -326,6 +348,34 @@
return $this->getETagFromResponse($response);
}
+ /**
+ * Patch a DAV object in a folder
+ *
+ * @param string $location Object location
+ * @param array $properties Object properties
+ *
+ * @return bool
+ */
+ public function propPatch($location, $properties = [])
+ {
+ [$props, $ns] = $this->objectPropertiesToXml($properties, 'xmlns:d="DAV:"');
+
+ if (empty($props)) {
+ return true;
+ }
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propertyupdate ' . $ns . '>'
+ . '<d:set>'
+ . '<d:prop>' . $props . '</d:prop>'
+ . '</d:set>'
+ . '</d:propertyupdate>';
+
+ $response = $this->request($location, 'PROPPATCH', $body);
+
+ return $response !== false;
+ }
+
/**
* Update a DAV object in a folder
*
@@ -423,18 +473,26 @@
* Create a DAV folder
*
* @param string $location Object location (relative to the user home)
- * @param string $component Content type (VEVENT, VTODO, VCARD)
+ * @param string $component Content type (VEVENT, VTODO, VCARD, NOTE)
* @param array $properties Object content
*
* @return bool True on success, False on error
*/
public function folderCreate($location, $component, $properties = [])
{
+ if ($component == 'NOTE') {
+ // We use name in the URL, no need to set displayname
+ unset($properties['name']);
+ }
+
[$props, $ns] = $this->folderPropertiesToXml($properties, 'xmlns:d="DAV:"');
if ($component == 'VCARD') {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"';
$props .= '<d:resourcetype><d:collection/><c:addressbook/></d:resourcetype>';
+ } elseif ($component == 'NOTE') {
+ $ns .= ' xmlns:k="' . self::KOLAB_NS . '"';
+ $props .= '<d:resourcetype><d:collection/><k:notebook/></d:resourcetype>';
} else {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
$props .= '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
@@ -533,6 +591,34 @@
return [$props, $ns];
}
+ /**
+ * Parse object properties input into XML string to use in a request
+ */
+ protected function objectPropertiesToXml($properties, $ns = '')
+ {
+ $props = '';
+
+ foreach ($properties as $name => $value) {
+ if ($name == 'title') {
+ $props .= '<d:displayname>' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . '</d:displayname>';
+ } elseif ($name == 'categories' || $name == 'links') {
+ if (!strpos($ns, self::KOLAB_NS)) {
+ $ns .= ' xmlns:k="' . self::KOLAB_NS . '"';
+ }
+
+ $list = '';
+ foreach ((array) $value as $item) {
+ $tag = $name == 'categories' ? 'category' : 'link';
+ $list .= "<k:{$tag}>" . htmlspecialchars($item, ENT_XML1, 'UTF-8') . "</k:{$tag}>";
+ }
+
+ $props .= "<k:{$name}>{$list}</k:{$name}>";
+ }
+ }
+
+ return [$props, $ns];
+ }
+
/**
* Fetch DAV notifications
*
@@ -665,12 +751,16 @@
* Fetch DAV objects metadata (ETag, href) a folder
*
* @param string $location Folder location
- * @param string $component Object type (VEVENT, VTODO, VCARD)
+ * @param string $component Object type (VEVENT, VTODO, VCARD, NOTE)
*
* @return false|array Objects metadata on success, False on error
*/
public function getIndex($location, $component = 'VEVENT')
{
+ if ($component == 'NOTE') {
+ return $this->getNotesIndex($location);
+ }
+
$queries = [
'VEVENT' => 'calendar-query',
'VTODO' => 'calendar-query',
@@ -717,8 +807,8 @@
* Fetch DAV objects data from a folder
*
* @param string $location Folder location
- * @param string $component Object type (VEVENT, VTODO, VCARD)
- * @param array $hrefs List of objects' locations to fetch (empty for all objects)
+ * @param string $component Object type (VEVENT, VTODO, VCARD, NOTE)
+ * @param array $hrefs List of objects' locations to fetch
*
* @return false|array Objects metadata on success, False on error
*/
@@ -728,6 +818,10 @@
return [];
}
+ if ($component == 'NOTE') {
+ return $this->getNotesData($location, $hrefs);
+ }
+
$body = '';
foreach ($hrefs as $href) {
$body .= '<d:href>' . $href . '</d:href>';
@@ -775,6 +869,94 @@
return $objects;
}
+ /**
+ * Fetch DAV notes index
+ *
+ * @param string $location Folder location
+ *
+ * @return false|array Notes metadata on success, False on error
+ */
+ public function getNotesIndex($location)
+ {
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:k="' . self::KOLAB_NS . '">'
+ . '<d:prop>'
+ . '<d:creationdate />'
+ . '<d:displayname />'
+ . '<d:getcontenttype />'
+ . '<d:getetag />'
+ . '<d:getlastmodified />'
+ . '<k:links />'
+ . '<k:categories />'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return false;
+ }
+
+ $elements = $response->getElementsByTagName('response');
+ $notes = [];
+
+ foreach ($elements as $element) {
+ $note = $this->getObjectPropertiesFromResponse($element, true);
+
+ if ($note['mimetype'] == 'text/html') {
+ $notes[] = $note;
+ }
+ }
+
+ return $notes;
+ }
+
+ /**
+ * Fetch DAV notes data from a folder
+ *
+ * @param string $location Folder location
+ * @param array $hrefs List of note locations to fetch
+ *
+ * @return false|array Objects metadata on success, False on error
+ */
+ public function getNotesData($location, $hrefs = [])
+ {
+ if (empty($hrefs)) {
+ return [];
+ }
+
+ // TODO: This is not optimal.
+ // 1. We have to get the whole index (no filter in PROPFIND) - which we already called in cache sync once.
+ // 2. We have to use a separate GET request for every note.
+ // Consider using some non-standard ways:
+ // - custom BPROPFIND request like https://learn.microsoft.com/en-us/previous-versions/office/developer/exchange-server-2003/aa142725(v=exchg.65)
+ // - custom REPORT
+ // with a custom property to include the note body.
+
+ $index = $this->getNotesIndex($location);
+
+ if (!is_array($index)) {
+ return false;
+ }
+
+ $result = [];
+
+ foreach ($hrefs as $href) {
+ foreach ($index as $i => $note) {
+ if ($note['href'] == $href) {
+ // Get the note body
+ if (($body = $this->request($href, 'GET', '', [], true)) !== false) {
+ $note['data'] = $body;
+ $result[] = $note;
+ }
+ continue;
+ }
+ }
+ }
+
+ return $result;
+ }
+
/**
* Accept/Deny a share invitation (draft-pot-webdav-resource-sharing)
*
@@ -1149,7 +1331,7 @@
/**
* Extract object properties from a server 'response' element
*/
- protected function getObjectPropertiesFromResponse(DOMElement $element)
+ protected function getObjectPropertiesFromResponse(DOMElement $element, $extra = false)
{
$uid = null;
if ($href = $element->getElementsByTagName('href')->item(0)) {
@@ -1174,12 +1356,41 @@
}
}
- return [
+ $result = [
'href' => $href,
'data' => $data,
'etag' => $etag,
'uid' => $uid,
];
+
+ if ($extra) {
+ $result['mimetype'] = strtolower((string) $element->getElementsByTagName('getcontenttype')->item(0)?->nodeValue);
+ $result['title'] = $element->getElementsByTagName('displayname')->item(0)?->nodeValue;
+
+ if (!isset($result['title'])) {
+ $result['title'] = pathinfo($href, PATHINFO_FILENAME);
+ }
+
+ if ($dt = $element->getElementsByTagName('creationdate')->item(0)?->nodeValue) {
+ $result['created'] = new DateTime($dt);
+ }
+
+ if ($dt = $element->getElementsByTagName('getlastmodified')->item(0)?->nodeValue) {
+ $result['changed'] = new DateTime($dt);
+ }
+
+ foreach (['links', 'categories'] as $name) {
+ $result[$name] = [];
+ if ($list = $element->getElementsByTagName($name)->item(0)) {
+ $tag = $name == 'categories' ? 'category' : 'link';
+ foreach ($list->getElementsByTagName($tag) as $item) {
+ $result[$name][] = $item->nodeValue;
+ }
+ }
+ }
+ }
+
+ return $result;
}
/**
diff --git a/plugins/libkolab/lib/kolab_storage.php b/plugins/libkolab/lib/kolab_storage.php
--- a/plugins/libkolab/lib/kolab_storage.php
+++ b/plugins/libkolab/lib/kolab_storage.php
@@ -528,7 +528,7 @@
$options = self::$imap->folder_info($oldfolder);
}
- if (!empty($options) && ($options['norename'] || $options['protected'])) {
+ if (!empty($options) && (!empty($options['norename']) || !empty($options['protected']))) {
}
// sanity checks (from steps/settings/save_folder.inc)
elseif (!strlen($folder)) {
@@ -577,7 +577,7 @@
}
// create new folder
else {
- $result = self::folder_create($folder, $prop['type'], $prop['subscribed'], $prop['active']);
+ $result = self::folder_create($folder, $prop['type'], !empty($prop['subscribed']), !empty($prop['active']));
}
if ($result) {
diff --git a/plugins/libkolab/lib/kolab_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php
--- a/plugins/libkolab/lib/kolab_storage_dav.php
+++ b/plugins/libkolab/lib/kolab_storage_dav.php
@@ -255,7 +255,22 @@
if (!empty($prop['id'])) {
if ($folder = $this->get_folder($prop['id'], $prop['type'])) {
- $result = $this->dav->folderUpdate($folder->href, $folder->get_dav_type(), $prop);
+ // For Notes folder update we just rename (move) it
+ if ($prop['type'] == 'note') {
+ $home = $this->dav->getHome('NOTE');
+ $location = unslashify($home) . '/' . rawurlencode($prop['name']);
+
+ if ($location != $folder->href) {
+ $result = $this->dav->move($folder->href, $location);
+ if ($result !== false) {
+ return self::folder_id($this->dav->url, $this->dav->normalize_location($location));
+ }
+ } else {
+ $result = true;
+ }
+ } else {
+ $result = $this->dav->folderUpdate($folder->href, $folder->get_dav_type(), $prop);
+ }
if ($result) {
return $prop['id'];
@@ -266,15 +281,21 @@
}
$rcube = rcube::get_instance();
- $uid = rtrim(chunk_split(md5($prop['name'] . $rcube->get_user_name() . uniqid('-', true)), 12, '-'), '-');
$type = $this->get_dav_type($prop['type']);
- $home = $this->dav->getHome($type);
+ $home = $this->dav->getHome($type);
if ($home === null) {
return false;
}
- $location = unslashify($home) . '/' . $uid;
+ if ($type == 'NOTE') {
+ // For WebDAV we will not use UIDs
+ $uid = $prop['name'];
+ } else {
+ $uid = rtrim(chunk_split(md5($prop['name'] . $rcube->get_user_name() . uniqid('-', true)), 12, '-'), '-');
+ }
+
+ $location = unslashify($home) . '/' . rawurlencode($uid);
$result = $this->dav->folderCreate($location, $type, $prop);
if ($result) {
@@ -557,9 +578,10 @@
'event' => 'VEVENT',
'task' => 'VTODO',
'contact' => 'VCARD',
+ 'note' => 'NOTE',
];
- return $types[$type];
+ return $types[$type] ?? null;
}
/**
diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache.php b/plugins/libkolab/lib/kolab_storage_dav_cache.php
--- a/plugins/libkolab/lib/kolab_storage_dav_cache.php
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache.php
@@ -217,13 +217,16 @@
}
// Remove deleted objects
- $old_index = array_filter($old_index);
+ $old_index = array_keys(array_filter($old_index));
if (!empty($old_index)) {
- $quoted_uids = implode(',', array_map([$this->db, 'quote'], $old_index));
- $this->db->query(
- "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)",
- $this->folder_id
- );
+ // Do it in chunks to handle huge number of deleted files
+ foreach (array_chunk($old_index, 1000) as $chunk) {
+ $quoted_uids = implode(',', array_map([$this->db, 'quote'], $chunk));
+ $this->db->query(
+ "DELETE FROM `{$this->cache_table}` WHERE `folder_id` = ? AND `uid` IN ($quoted_uids)",
+ $this->folder_id
+ );
+ }
}
return true;
@@ -668,20 +671,27 @@
if (!empty($fast_mode) && !empty($object)) {
unset($object['_raw']);
- } elseif ($noread) {
+ } elseif ($noread && !empty($object['_raw'])) {
// We have the raw content already, parse it
- if (!empty($object['_raw'])) {
- $object['data'] = $object['_raw'];
- if ($object = $this->folder->from_dav($object)) {
- $init($object);
- return $object;
- }
+ $object['data'] = $object['_raw'];
+ if ($object = $this->folder->from_dav($object)) {
+ $init($object);
+ return $object;
}
return null;
} else {
+ $_object = $object ?? [];
+
// Fetch a complete object from the server
$object = $this->folder->read_object($sql_arr['uid'], '*');
+
+ // Copy some props that may not be available by just reading the object content (e.g. for notes)
+ foreach (array_merge($this->data_props, ['created', 'changed']) as $prop) {
+ if (!isset($object[$prop]) && isset($_object[$prop])) {
+ $object[$prop] = $_object[$prop];
+ }
+ }
}
return $object;
diff --git a/plugins/libkolab/lib/kolab_storage_dav_cache_note.php b/plugins/libkolab/lib/kolab_storage_dav_cache_note.php
new file mode 100644
--- /dev/null
+++ b/plugins/libkolab/lib/kolab_storage_dav_cache_note.php
@@ -0,0 +1,102 @@
+<?php
+
+/**
+ * Kolab storage cache class for note objects
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) 2013-2022 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_dav_cache_note extends kolab_storage_dav_cache
+{
+ protected $data_props = ['categories', 'links', 'title'];
+ protected $fulltext_cols = ['categories', 'title', 'description'];
+
+ /**
+ * Helper method to convert the given Kolab object into a dataset to be written to cache
+ *
+ * @override
+ */
+ protected function _serialize($object)
+ {
+ $sql_data = parent::_serialize($object);
+
+ $sql_data['tags'] = ' ' . implode(' ', $this->get_tags($object)) . ' ';
+ $sql_data['words'] = ' ' . implode(' ', $this->get_words($object)) . ' ';
+
+ return $sql_data;
+ }
+ /**
+ * Callback for kolab_storage_cache to get object specific tags to cache
+ *
+ * @return array List of tags to save in cache
+ */
+ public function get_tags($object)
+ {
+ $tags = [];
+
+ foreach ((array)($object['categories'] ?? null) as $cat) {
+ $tags[] = rcube_utils::normalize_string($cat);
+ }
+
+ // add tag for message references
+ foreach ((array)($object['links'] ?? []) as $link) {
+ $url = parse_url(str_replace(':///', '://', $link));
+ if ($url['scheme'] == 'imap') {
+ parse_str($url['query'], $param);
+ $tags[] = 'ref:' . trim($param['message-id'] ?: urldecode($url['fragment']), '<> ');
+ }
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Callback to get words to index for fulltext search
+ *
+ * @return array List of words to save in cache
+ */
+ public function get_words($object = [])
+ {
+ $data = '';
+
+ foreach ($this->fulltext_cols as $col) {
+ if (empty($object[$col])) {
+ continue;
+ }
+
+ // convert HTML content to plain text
+ if ($col == 'description'
+ && preg_match('/<(html|body)(\s[a-z]|>)/', $object[$col], $m)
+ && strpos($object[$col], '</' . $m[1] . '>')
+ ) {
+ $converter = new rcube_html2text($object[$col], false, false, 0);
+ $val = $converter->get_text();
+ } else {
+ $val = is_array($object[$col]) ? implode(' ', $object[$col]) : $object[$col];
+ }
+
+ if (is_string($val) && strlen($val)) {
+ $data .= $val . ' ';
+ }
+ }
+
+ $words = rcube_utils::normalize_string($data, true);
+
+ return array_unique($words);
+ }
+}
diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php
--- a/plugins/libkolab/lib/kolab_storage_dav_folder.php
+++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php
@@ -50,7 +50,7 @@
// For DAV we don't really have a standard way to define "default" folders,
// but MS Outlook (ActiveSync) requires them. This will work with Cyrus DAV.
- $rx = "~/(calendars|addressbooks)/user/[^/]+/(Default|Tasks)/?$~";
+ $rx = "~/(calendars|addressbooks|files)/user/[^/]+/(Default|Tasks|Notes)/?$~";
$rx = rcube::get_instance()->config->get('kolab_dav_default_folder_regex') ?: $rx;
$this->default = preg_match($rx, $this->href) === 1;
@@ -166,7 +166,7 @@
*/
public function get_name()
{
- $name = $this->attributes['name'];
+ $name = $this->get_foldername();
if ($this->get_namespace() == 'other') {
$name = $this->get_owner() . ': ' . $name;
@@ -182,7 +182,14 @@
*/
public function get_foldername()
{
- return $this->attributes['name'];
+ $name = $this->attributes['name'] ?? null;
+
+ if ($name === null) {
+ $path = explode('/', trim($this->href, '/'));
+ return rawurldecode(array_last($path));
+ }
+
+ return $name;
}
/**
@@ -547,7 +554,8 @@
if ($content = $this->to_dav($object)) {
$method = $uid ? 'update' : 'create';
$dav_type = $this->get_dav_type();
- $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type);
+ $location = $this->object_location($object['uid']);
+ $result = $this->dav->{$method}($location, $content, $dav_type);
// Note: $result can be NULL if the request was successful, but ETag wasn't returned
if ($result !== false) {
@@ -557,6 +565,10 @@
$this->cache->save($object, $uid);
$result = true;
unset($object['_raw']);
+
+ if ($props = $this->object_props($object)) {
+ $this->dav->propPatch($location, $props);
+ }
}
}
@@ -653,7 +665,7 @@
/**
* Convert DAV object into PHP array
*
- * @param array $object Object data in kolab_dav_client::fetchData() format
+ * @param array $object Object data in kolab_dav_client::getData() format
*
* @return array|false Object properties, False on error
*/
@@ -727,6 +739,14 @@
} else {
return false;
}
+ } elseif ($this->type == 'note') {
+ $result['description'] = $object['data'];
+
+ foreach (['created', 'changed', 'title', 'links', 'categories'] as $key) {
+ if (isset($object[$key])) {
+ $result[$key] = $object[$key];
+ }
+ }
}
$result['etag'] = $object['etag'];
@@ -849,6 +869,8 @@
$result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result);
$result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result);
}
+ } elseif ($this->type == 'note') {
+ $result = $object['description'] ?? '';
}
if ($result) {
@@ -865,6 +887,19 @@
return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext();
}
+ protected function object_props($object)
+ {
+ $result = [];
+
+ if ($this->type == 'note') {
+ $result['title'] = $object['title'];
+ $result['categories'] = $object['categories'] ?? [];
+ $result['links'] = $object['links'] ?? [];
+ }
+
+ return rcube_charset::clean($result);
+ }
+
/**
* Get a folder DAV content type
*/
@@ -920,6 +955,7 @@
'event' => 'ics',
'task' => 'ics',
'contact' => 'vcf',
+ 'note' => 'html',
];
return $types[$this->type];
@@ -993,6 +1029,6 @@
*/
public function __toString()
{
- return $this->attributes['name'];
+ return $this->get_foldername();
}
}
diff --git a/plugins/libkolab/lib/kolab_storage_tags.php b/plugins/libkolab/lib/kolab_storage_tags.php
--- a/plugins/libkolab/lib/kolab_storage_tags.php
+++ b/plugins/libkolab/lib/kolab_storage_tags.php
@@ -72,6 +72,40 @@
return array_values($tags);
}
+ /**
+ * Get list of tag names and compare it with the existing tags.
+ * Creates new tags that do not exist yet.
+ *
+ * @param array<string> $list List of tag names
+ * @param bool $create Create tags that do not exist
+ *
+ * @return array List of existing tag names
+ */
+ public function compute($list, bool $create = true)
+ {
+ $tags = $this->list_tags();
+ $names = array_column($tags, 'name');
+ $update = false;
+
+ foreach (array_unique(array_filter($list)) as $idx => $tag_name) {
+ if (!in_array($tag_name, $names)) {
+ if ($create) {
+ $tags[] = ['name' => $tag_name];
+ $names[] = $tag_name;
+ $update = true;
+ } else {
+ unset($list[$idx]);
+ }
+ }
+ }
+
+ if ($update) {
+ $this->save_tags($tags);
+ }
+
+ return array_values($list);
+ }
+
/**
* Create tag object
*
diff --git a/plugins/libkolab/lib/kolab_subscriptions.php b/plugins/libkolab/lib/kolab_subscriptions.php
--- a/plugins/libkolab/lib/kolab_subscriptions.php
+++ b/plugins/libkolab/lib/kolab_subscriptions.php
@@ -88,9 +88,7 @@
}
if ($this->dav) {
- if ($type == 'note') {
- $result = [];
- } elseif ($type == 'mail') {
+ if ($type == 'mail') {
$storage = $this->rc->get_storage();
$result = $storage->list_folders();
@@ -165,10 +163,6 @@
*/
public function list_subscriptions($deviceid, $type)
{
- if ($this->dav && $type == 'note') {
- return [];
- }
-
$result = $this->get_subscriptions($deviceid, $type);
$devicetype = $this->imei_to_type($deviceid);
@@ -287,10 +281,6 @@
// Folder hrefs returned by kolab_dav_client aren't normalized, i.e. include path prefix
// We make sure here we use the same path
if ($this->dav && $type != 'mail') {
- if ($type == 'note') {
- return false;
- }
-
if ($path = parse_url($this->dav->dav->url, PHP_URL_PATH)) {
if (strpos($folder, $path) !== 0) {
$folder = '/' . trim($path, '/') . $folder;
@@ -334,10 +324,6 @@
return false;
}
- if ($this->dav && $type == 'note') {
- return true;
- }
-
$data = json_encode($subscriptions);
if ($data === false) {
diff --git a/plugins/libkolab/skins/elastic/include/kolab_notes.less b/plugins/libkolab/skins/elastic/include/kolab_notes.less
--- a/plugins/libkolab/skins/elastic/include/kolab_notes.less
+++ b/plugins/libkolab/skins/elastic/include/kolab_notes.less
@@ -88,7 +88,7 @@
.font-icon-regular(@fa-var-sticky-note) !important;
}
- & > a {
+ & a {
.overflow-ellipsis();
white-space: nowrap;
display: block;
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 1:30 AM (21 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18821923
Default Alt Text
D5832.1775179852.diff (142 KB)
Attached To
Mode
D5832: WebDAV Notes
Attached
Detach File
Event Timeline