Page MenuHomePhorge

D5832.1775187859.diff
No OneTemporary

Authored By
Unknown
Size
136 KB
Referenced Files
None
Subscribers
None

D5832.1775187859.diff

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/files/user/%u'
+$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,1037 @@
+<?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;
+ }
+
+ // 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);
+ }
+
+ 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,613 @@
+<?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'] ?: $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)) {
+ 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;
+ }
+
+ // 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;
+ }
+ }
+
+ $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,8 @@
switch ($action) {
case 'new':
case 'edit':
- if ($success = $this->save_note($note)) {
- $refresh = $this->get_note($note);
+ if ($success = $this->driver->save_note($note)) {
+ $refresh = $this->driver->get_note($note);
}
break;
@@ -633,8 +280,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 +291,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 +320,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 +329,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 +338,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 +353,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 +367,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 +375,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 +414,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 +431,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 +481,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 +490,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 +503,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 +521,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 +548,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 +580,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 +626,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 +692,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/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');
@@ -122,7 +124,7 @@
$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 +181,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;
@@ -225,6 +227,11 @@
return $path;
}
+ if ($type == 'NOTE') {
+ // No discovery, we'll use the configured root
+ return $this->path;
+ }
+
$options = [
'VEVENT' => 'calendar-home-set',
'VTODO' => 'calendar-home-set',
@@ -244,7 +251,7 @@
/**
* 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
*/
@@ -294,6 +301,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 +318,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 +328,7 @@
'VEVENT' => 'text/calendar',
'VTODO' => 'text/calendar',
'VCARD' => 'text/vcard',
+ 'NOTE' => 'text/html',
];
$headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8'];
@@ -326,6 +338,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 +463,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 +581,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 +741,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 +797,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 +808,10 @@
return [];
}
+ if ($component == 'NOTE') {
+ return $this->getNotesData($location, $hrefs);
+ }
+
$body = '';
foreach ($hrefs as $href) {
$body .= '<d:href>' . $href . '</d:href>';
@@ -775,6 +859,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 +1321,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 +1346,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/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

Mime Type
text/plain
Expires
Fri, Apr 3, 3:44 AM (16 h, 43 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822464
Default Alt Text
D5832.1775187859.diff (136 KB)

Event Timeline