Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117752022
D5835.1775187705.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
77 KB
Referenced Files
None
Subscribers
None
D5835.1775187705.diff
View Options
diff --git a/docker/roundcube/Dockerfile b/docker/roundcube/Dockerfile
--- a/docker/roundcube/Dockerfile
+++ b/docker/roundcube/Dockerfile
@@ -112,6 +112,7 @@
# ENV KOLAB_API_URL =
# ENV KOLAB_HELPDESK_ALLOWED_TASKS=
# ENV KOLAB_API_DEBUG=
+# ENV KOLAB_NOTES_WEBDAV_SERVER=
# ENV IMAP_HOST=
# ENV IMAP_PORT=
# ENV IMAP_TLS=
diff --git a/docker/roundcube/rootfs/opt/app-root/src/checkconnections.sh b/docker/roundcube/rootfs/opt/app-root/src/checkconnections.sh
--- a/docker/roundcube/rootfs/opt/app-root/src/checkconnections.sh
+++ b/docker/roundcube/rootfs/opt/app-root/src/checkconnections.sh
@@ -90,16 +90,4 @@
fi
-if [[ "$(./getconfig.php calendar_driver)" == "caldav" ]]; then
- #$config['calendar_caldav_server'] = "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav";
- URL=$(./getconfig.php calendar_caldav_server)
- echo "Caldav $URL"
- curl -sD /dev/stderr -H "Content-Type: application/xml" -X PROPFIND -H "Depth: infinity" --data '<d:propfind xmlns:d="DAV:" xmlns:cs="https://calendarserver.org/ns/"><d:prop><d:resourcetype /><d:displayname /></d:prop></d:propfind>' $URL -k | grep "405 Method Not Allowed"
- echo "Caldav is OK"
-
- #FIXME this is for external access, so we can't test this here
- #$config['calendar_caldav_url'] = 'http://%h/dav/calendars/%u/%i';
-fi
-
-
echo "All checks complete"
diff --git a/docker/roundcube/rootfs/opt/app-root/src/roundcubemail-config-templates/kolab.inc.php b/docker/roundcube/rootfs/opt/app-root/src/roundcubemail-config-templates/kolab.inc.php
--- a/docker/roundcube/rootfs/opt/app-root/src/roundcubemail-config-templates/kolab.inc.php
+++ b/docker/roundcube/rootfs/opt/app-root/src/roundcubemail-config-templates/kolab.inc.php
@@ -20,24 +20,27 @@
'plugins' => ['kolab_config', 'kolab_folders'],
'calendar_driver' => 'kolab',
'fileapi_backend' => 'kolab',
+ 'kolab_addressbook_driver' => 'kolab',
'kolab_tags_driver' => 'kolab',
+ 'kolab_notes_driver' => 'kolab',
'tasklist_driver' => 'kolab',
- 'kolab_addressbook_driver' => 'kolab'
];
$config['configuration-overlays']['kolab4'] = [
+ 'activesync_storage' => 'kolab4',
+ 'activesync_dav_server' => getenv('CALENDAR_CALDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
'calendar_driver' => 'caldav',
'calendar_caldav_server' => getenv('CALENDAR_CALDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
'fileapi_backend' => 'kolabfiles',
'fileapi_kolabfiles_baseuri' => getenv('FILEAPI_KOLABFILES_BASEURI'),
- 'activesync_storage' => 'kolab4',
- 'activesync_dav_server' => getenv('CALENDAR_CALDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
- 'kolab_tags_driver' => 'annotate',
+ 'kolab_addressbook_driver' => 'carddav',
+ 'kolab_addressbook_carddav_server' => getenv('KOLAB_ADDRESSBOOK_CARDDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
'kolab_dav_sharing' => 'sharing',
+ 'kolab_notes_driver' => 'webdav',
+ 'kolab_notes_webdav_server' => getenv('KOLAB_NOTES_WEBDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
+ 'kolab_tags_driver' => 'annotate',
'tasklist_driver' => 'caldav',
'tasklist_caldav_server' => getenv('TASKLIST_CALDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
- 'kolab_addressbook_driver' => 'carddav',
- 'kolab_addressbook_carddav_server' => getenv('KOLAB_ADDRESSBOOK_CARDDAV_SERVER') ?: "https://" . ($_SERVER["HTTP_HOST"] ?? null) . "/dav",
];
$config['configuration-overlays']['activesync'] = [
@@ -49,7 +52,7 @@
];
$config['configuration-overlays']['groupware'] = [
- 'plugins' => ['calendar', 'kolab_files', 'kolab_addressbook', 'kolab_tags', 'tasklist']
+ 'plugins' => ['calendar', 'kolab_files', 'kolab_addressbook', 'kolab_tags', 'kolab_notes', 'tasklist']
];
$config['configuration-overlays']['groupware-kolabobjects'] = [
diff --git a/src/app/Backends/DAV.php b/src/app/Backends/DAV.php
--- a/src/app/Backends/DAV.php
+++ b/src/app/Backends/DAV.php
@@ -13,11 +13,13 @@
public const TYPE_VTODO = 'VTODO';
public const TYPE_VCARD = 'VCARD';
public const TYPE_NOTIFICATION = 'NOTIFICATION';
+ public const TYPE_NOTE = 'NOTE';
public const NAMESPACES = [
self::TYPE_VEVENT => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VTODO => 'urn:ietf:params:xml:ns:caldav',
self::TYPE_VCARD => 'urn:ietf:params:xml:ns:carddav',
+ self::TYPE_NOTE => 'Kolab:',
];
protected $url;
@@ -66,6 +68,20 @@
$this->password = $password;
}
+ /**
+ * Return collection resource type for specified object type
+ */
+ public static function collectionType($type): ?string
+ {
+ return match ($type) {
+ self::TYPE_VCARD => 'addressbook',
+ self::TYPE_NOTE => 'notebook',
+ self::TYPE_VEVENT => 'calendar',
+ self::TYPE_VTODO => 'calendar',
+ default => null,
+ };
+ }
+
/**
* Discover DAV home (root) collection of a specified type.
*
@@ -161,6 +177,10 @@
*/
public function getHome($type)
{
+ if ($type == self::TYPE_NOTE) {
+ return 'dav/files/user/' . $this->user;
+ }
+
$options = [
self::TYPE_VEVENT => 'calendar-home-set',
self::TYPE_VTODO => 'calendar-home-set',
@@ -184,6 +204,8 @@
*/
public static function healthcheck($username, $password): bool
{
+ // TODO: healthcheck for the built-in WebDAV server?
+
$homes = self::getInstance($username, $password)->discover();
return !empty($homes);
}
@@ -218,9 +240,10 @@
foreach ($response->getElementsByTagName('response') as $element) {
$folder = DAV\Folder::fromDomElement($element);
- // Note: Addressbooks don't have 'type' specified
+ // Note: Addressbooks and Notebooks don't have components specified
if (
($component == self::TYPE_VCARD && in_array('addressbook', $folder->types))
+ || ($component == self::TYPE_NOTE && in_array('notebook', $folder->types))
|| in_array($component, $folder->components)
) {
$folders[] = $folder;
@@ -439,6 +462,40 @@
return $response !== false;
}
+ /**
+ * Fetch DAV notes
+ *
+ * @param string $location Folder location
+ *
+ * @return array<DAV\Note> Notes objects
+ */
+ public function listNotes($location): array
+ {
+ // FIXME: As far as I can see there's no other way to get only the notes we're interested in
+
+ $body = DAV\Note::propfindXML();
+
+ $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
+
+ if (empty($response)) {
+ return [];
+ }
+
+ $notes = [];
+
+ foreach ($response->getElementsByTagName('response') as $element) {
+ $note = $this->objectFromElement($element, self::TYPE_NOTE);
+ if ($note->mimetype == 'text/html') {
+ // skip non-note elements (e.g. the parent folder)
+ continue;
+ }
+
+ $notes[] = $note;
+ }
+
+ return $notes;
+ }
+
/**
* Fetch DAV notifications
*
@@ -503,6 +560,25 @@
return false;
}
+ /**
+ * Patch a DAV object in a folder
+ *
+ * @param DAV\CommonObject $object Object
+ */
+ public function propPatch(DAV\CommonObject $object): bool
+ {
+ $xml = $object->toXML();
+
+ if (!strlen($xml)) {
+ // This type of object is not updateable via PROPPATCH
+ return true;
+ }
+
+ $response = $this->request($object->href, 'PROPPATCH', $xml);
+
+ return $response !== false;
+ }
+
/**
* Search DAV objects in a folder.
*
@@ -821,6 +897,9 @@
case self::TYPE_VCARD:
$object = DAV\Vcard::fromDomElement($element);
break;
+ case self::TYPE_NOTE:
+ $object = DAV\Note::fromDomElement($element);
+ break;
default:
throw new \Exception("Unknown component: {$component}");
}
diff --git a/src/app/Backends/DAV/CommonObject.php b/src/app/Backends/DAV/CommonObject.php
--- a/src/app/Backends/DAV/CommonObject.php
+++ b/src/app/Backends/DAV/CommonObject.php
@@ -48,6 +48,15 @@
return $object;
}
+ /**
+ * Return object as XML for PROPPATCH request
+ */
+ public function toXML(): string
+ {
+ // do nothing by default
+ return '';
+ }
+
/**
* Make the item compatible with standards (and Cyrus DAV) by fixing
* obvious issues, if possible
diff --git a/src/app/Backends/DAV/Folder.php b/src/app/Backends/DAV/Folder.php
--- a/src/app/Backends/DAV/Folder.php
+++ b/src/app/Backends/DAV/Folder.php
@@ -164,15 +164,18 @@
if (in_array('addressbook', $this->types)) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"';
- $type = 'addressbook';
+ $type = 'c:addressbook';
} elseif (in_array('calendar', $this->types)) {
$ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
- $type = 'calendar';
+ $type = 'c:calendar';
+ } elseif (in_array('notebook', $this->types)) {
+ $ns .= ' xmlns:k="Kolab:"';
+ $type = 'k:notebook';
}
// Cyrus DAV does not allow resourcetype property change
if ($tag != 'propertyupdate') {
- $props .= '<d:resourcetype><d:collection/>' . ($type ? "<c:{$type}/>" : '') . '</d:resourcetype>';
+ $props .= '<d:resourcetype><d:collection/>' . ($type ? "<{$type}/>" : '') . '</d:resourcetype>';
}
if (!empty($this->components)) {
diff --git a/src/app/Backends/DAV/Note.php b/src/app/Backends/DAV/Note.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Note.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Note extends CommonObject
+{
+ /** @var array Note categories (tags) */
+ public $categories = [];
+
+ /** @var ?string Note title */
+ public $displayname;
+
+ /** @var ?\DateTime Note last modification date-time */
+ public $lastModified;
+
+ /** @var array Note links */
+ public $links = [];
+
+ /** @var ?string File type */
+ public $mimetype;
+
+ /**
+ * Create Note object from a DOMElement element
+ *
+ * @param \DOMElement $element DOM element with notification properties
+ *
+ * @return Note
+ */
+ public static function fromDomElement(\DOMElement $element)
+ {
+ $note = new self();
+
+ if ($href = $element->getElementsByTagName('href')->item(0)) {
+ $note->href = $href->nodeValue;
+ $note->uid = preg_replace('/\.[a-z]+$/', '', pathinfo($note->href, \PATHINFO_FILENAME));
+ }
+
+ $note->mimetype = strtolower((string) $element->getElementsByTagName('getcontenttype')->item(0)?->nodeValue);
+ $note->displayname = $element->getElementsByTagName('displayname')->item(0)?->nodeValue;
+
+ if ($dt = $element->getElementsByTagName('getlastmodified')->item(0)?->nodeValue) {
+ $note->lastModified = new \DateTime($dt);
+ }
+
+ foreach (['links', 'categories'] as $name) {
+ if ($list = $element->getElementsByTagName($name)->item(0)) {
+ $tag = $name == 'categories' ? 'category' : 'link';
+ foreach ($list->getElementsByTagName($tag) as $item) {
+ $note->{$name}[] = $item->nodeValue;
+ }
+ }
+ }
+
+ return $note;
+ }
+
+ /**
+ * Get XML string for PROPPATCH request
+ */
+ public function toXML(): string
+ {
+ $props = '<d:displayname>' . htmlspecialchars($this->displayname, \ENT_XML1, 'UTF-8') . '</d:displayname>';
+
+ foreach (['categories', 'links'] as $name) {
+ $list = '';
+ foreach ($this->{$name} 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 '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propertyupdate xmlns:d="DAV:" xmlns:k="Kolab:">'
+ . '<d:set>'
+ . '<d:prop>' . $props . '</d:prop>'
+ . '</d:set>'
+ . '</d:propertyupdate>';
+ }
+
+ /**
+ * Get XML string for PROPFIND query on a notification
+ *
+ * @return string
+ */
+ public static function propfindXML()
+ {
+ // Note: With <d:allprop/> notificationtype is not returned, but it's essential
+ return '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:propfind xmlns:d="DAV:" xmlns:k="Kolab:">'
+ . '<d:prop>'
+ . '<d:displayname />'
+ . '<d:getcontenttype />'
+ . '<d:getlastmodified />'
+ . '<k:links />'
+ . '<k:categories />'
+ . '</d:prop>'
+ . '</d:propfind>';
+ }
+}
diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php
--- a/src/app/Backends/Storage.php
+++ b/src/app/Backends/Storage.php
@@ -213,6 +213,10 @@
if ($file->type & Item::TYPE_INCOMPLETE) {
$file->type -= Item::TYPE_INCOMPLETE;
$file->save();
+ } else {
+ // Bump last modification time (needed e.g. for proper WebDAV syncronization/ETag)
+ // Note: We don't use touch() directly on $file because it fails when the object has custom properties
+ Item::where('id', $file->id)->touch();
}
// Update the file type and size information
diff --git a/src/app/DataMigrator/Driver/DAV.php b/src/app/DataMigrator/Driver/DAV.php
--- a/src/app/DataMigrator/Driver/DAV.php
+++ b/src/app/DataMigrator/Driver/DAV.php
@@ -149,7 +149,7 @@
if (!$href) {
$home = $this->client->getHome($dav_type);
$folder_id = Utils::uuidStr();
- $collection_type = $dav_type == DAVClient::TYPE_VCARD ? 'addressbook' : 'calendar';
+ $collection_type = DAVClient::collectionType($dav_type);
// We create all folders on the top-level
$dav_folder = new DAVFolder();
diff --git a/src/app/DataMigrator/Driver/Kolab.php b/src/app/DataMigrator/Driver/Kolab.php
--- a/src/app/DataMigrator/Driver/Kolab.php
+++ b/src/app/DataMigrator/Driver/Kolab.php
@@ -15,12 +15,12 @@
*/
class Kolab extends IMAP
{
- protected const CTYPE_KEY = '/shared/vendor/kolab/folder-type';
- protected const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type';
- protected const UID_KEY = '/shared/vendor/kolab/uniqueid';
- protected const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
- protected const COLOR_KEY = '/shared/vendor/kolab/color';
- protected const COLOR_KEY_PRIVATE = '/private/vendor/kolab/color';
+ public const CTYPE_KEY = '/shared/vendor/kolab/folder-type';
+ public const CTYPE_KEY_PRIVATE = '/private/vendor/kolab/folder-type';
+ public const UID_KEY = '/shared/vendor/kolab/uniqueid';
+ public const UID_KEY_CYRUS = '/shared/vendor/cmu/cyrus-imapd/uniqueid';
+ public const COLOR_KEY = '/shared/vendor/kolab/color';
+ public const COLOR_KEY_PRIVATE = '/private/vendor/kolab/color';
protected const DAV_TYPES = [
Engine::TYPE_CONTACT,
@@ -31,6 +31,7 @@
Engine::TYPE_MAIL,
Engine::TYPE_CONFIGURATION,
Engine::TYPE_FILE,
+ Engine::TYPE_NOTE,
];
/** @var DAV DAV importer/exporter engine */
@@ -118,6 +119,12 @@
Kolab\Files::createFolder($this->account, $folder);
return;
}
+
+ // Notes
+ if ($folder->type == Engine::TYPE_NOTE) {
+ Kolab\Notes::createFolder($this->account, $folder);
+ return;
+ }
}
/**
@@ -152,6 +159,12 @@
return;
}
+ // Notes
+ if ($item->folder->type == Engine::TYPE_NOTE) {
+ Kolab\Notes::saveKolab4Note($this->account, $item);
+ return;
+ }
+
// Configuration (v3 tags)
if ($item->folder->type == Engine::TYPE_CONFIGURATION) {
$this->initIMAP();
@@ -172,7 +185,7 @@
// IMAP (and DAV)
// TODO: We can treat 'file' folders the same, but we have no sharing in Kolab4 yet for them
- if ($folder->type == Engine::TYPE_MAIL || in_array($folder->type, self::DAV_TYPES)) {
+ if (in_array($folder->type, array_merge(self::DAV_TYPES, [Engine::TYPE_MAIL, Engine::TYPE_NOTE]))) {
parent::fetchFolder($folder);
return;
}
@@ -200,6 +213,13 @@
return;
}
+ // Notes (IMAP)
+ if ($item->folder->type == Engine::TYPE_NOTE) {
+ $this->initIMAP();
+ Kolab\Notes::fetchKolab3Note($this->imap, $item);
+ return;
+ }
+
// Files (IMAP)
if ($item->folder->type == Engine::TYPE_FILE) {
$this->initIMAP();
@@ -237,6 +257,21 @@
return;
}
+ // Notes
+ if ($folder->type == Engine::TYPE_NOTE) {
+ $this->initIMAP();
+
+ // Get existing notes from the destination account
+ $existing = $importer->getItems($folder);
+
+ $mailbox = self::toUTF7($folder->fullname);
+ foreach (Kolab\Notes::getKolab3Notes($this->imap, $mailbox, $existing) as $note) {
+ $note['folder'] = $folder;
+ $item = Item::fromArray($note);
+ $callback($item);
+ }
+ }
+
// Files
if ($folder->type == Engine::TYPE_FILE) {
$this->initIMAP();
@@ -404,6 +439,11 @@
return $this->davDriver->getItems($folder);
}
+ // Notes
+ if ($folder->type == Engine::TYPE_NOTE) {
+ return Kolab\Notes::getKolab4Notes($this->account, $folder);
+ }
+
// Files
if ($folder->type == Engine::TYPE_FILE) {
return Kolab\Files::getKolab4Files($this->account, $folder);
diff --git a/src/app/DataMigrator/Driver/Kolab/Files.php b/src/app/DataMigrator/Driver/Kolab/Files.php
--- a/src/app/DataMigrator/Driver/Kolab/Files.php
+++ b/src/app/DataMigrator/Driver/Kolab/Files.php
@@ -13,20 +13,8 @@
/**
* Utilities to handle/migrate Kolab (v3 and v4) files
*/
-class Files
+class Files extends Fs
{
- /**
- * Create a Kolab4 files collection (folder)
- *
- * @param Account $account Destination account
- * @param Folder $folder Folder object
- */
- public static function createFolder(Account $account, Folder $folder): void
- {
- // We assume destination is the local server. Maybe we should be using Cockpit API?
- self::getFsCollection($account, $folder, true);
- }
-
/**
* Get file properties/content
*
@@ -172,15 +160,10 @@
if ($item->existing) {
/** @var FsItem $file */
$file = $item->existing;
- $file->updated_at = $item->data['mtime'];
- $file->timestamps = false;
- $file->save();
} else {
$file = new FsItem();
$file->user_id = $account->getUser()->id;
$file->type = FsItem::TYPE_FILE;
- $file->updated_at = $item->data['mtime'];
- $file->timestamps = false;
$file->save();
$file->properties()->create(['key' => 'name', 'value' => $item->data['name']]);
@@ -199,72 +182,16 @@
Storage::fileInput($fp, $params, $file);
- DB::commit();
-
- fclose($fp);
- }
-
- /**
- * Find (and optionally create) a Kolab4 files collection
- *
- * @param Account $account Destination account
- * @param Folder $folder Folder object
- * @param bool $create Create collection(s) if it does not exist
- *
- * @return ?FsItem Collection object if found
- */
- protected static function getFsCollection(Account $account, Folder $folder, bool $create = false)
- {
- if (!empty($folder->data['collection'])) {
- return $folder->data['collection'];
- }
-
- // We assume destination is the local server. Maybe we should be using Cockpit API?
- $user = $account->getUser();
-
- // TODO: For now we assume '/' is the IMAP hierarchy separator. This may not work with dovecot.
- $path = explode('/', $folder->fullname);
- $collection = null;
-
- // Create folder (and the whole tree) if it does not exist yet
- foreach ($path as $name) {
- $result = $user->fsItems()->select('fs_items.*');
-
- if ($collection) {
- $result->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
- ->where('fs_relations.item_id', $collection->id);
- } else {
- $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
- ->whereNull('fs_relations.related_id');
- }
-
- $found = $result->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
- ->where('type', '&', FsItem::TYPE_COLLECTION)
- ->where('key', 'name')
- ->where('value', $name)
- ->first();
-
- if (!$found) {
- if ($create) {
- DB::beginTransaction();
- $col = $user->fsItems()->create(['type' => FsItem::TYPE_COLLECTION]);
- $col->properties()->create(['key' => 'name', 'value' => $name]);
- if ($collection) {
- $collection->relations()->create(['related_id' => $col->id]);
- }
- $collection = $col;
- DB::commit();
- } else {
- return null;
- }
- } else {
- $collection = $found;
- }
+ // Update the mtime, must be after fileInput() call
+ if (!empty($item->data['mtime'])) {
+ $file->updated_at = $item->data['mtime'];
+ $file->timestamps = false;
+ $file->save();
}
- $folder->data['collection'] = $collection;
+ DB::commit();
- return $collection;
+ fclose($fp);
}
/**
diff --git a/src/app/DataMigrator/Driver/Kolab/Fs.php b/src/app/DataMigrator/Driver/Kolab/Fs.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Driver/Kolab/Fs.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace App\DataMigrator\Driver\Kolab;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Interface\Folder;
+use App\Fs\Item as FsItem;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Utility to handle migration to Kolab v4 file storage
+ */
+class Fs
+{
+ /**
+ * Create a Kolab4 files collection (folder)
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder object
+ */
+ public static function createFolder(Account $account, Folder $folder): void
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ self::getFsCollection($account, $folder, true);
+ }
+
+ /**
+ * Find (and optionally create) a Kolab4 files collection
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder object
+ * @param bool $create Create collection(s) if it does not exist
+ * @param bool $flat Flatten the folder hierarchy
+ *
+ * @return ?FsItem Collection object if found
+ */
+ protected static function getFsCollection(Account $account, Folder $folder, bool $create = false, bool $flat = false)
+ {
+ if (!empty($folder->data['collection'])) {
+ return $folder->data['collection'];
+ }
+
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ $user = $account->getUser();
+
+ // TODO: For now we assume '/' is the IMAP hierarchy separator. This may not work with dovecot.
+ if ($flat) {
+ $path = [str_replace('/', ' » ', $folder->fullname)];
+ } else {
+ $path = explode('/', $folder->fullname);
+ }
+
+ $collection = null;
+
+ // Create folder (and the whole tree) if it does not exist yet
+ foreach ($path as $name) {
+ $result = $user->fsItems()->select('fs_items.*');
+
+ if ($collection) {
+ $result->join('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->where('fs_relations.item_id', $collection->id);
+ } else {
+ $result->leftJoin('fs_relations', 'fs_items.id', '=', 'fs_relations.related_id')
+ ->whereNull('fs_relations.related_id');
+ }
+
+ $found = $result->join('fs_properties', 'fs_items.id', '=', 'fs_properties.item_id')
+ ->where('type', '&', FsItem::TYPE_COLLECTION)
+ ->where('key', 'name')
+ ->where('value', $name)
+ ->first();
+
+ if (!$found) {
+ if ($create) {
+ $coltype = FsItem::TYPE_COLLECTION;
+ if ($folder->type == 'note') {
+ $coltype |= FsItem::TYPE_NOTEBOOK;
+ }
+
+ DB::beginTransaction();
+ $col = $user->fsItems()->create(['type' => $coltype]);
+ $col->properties()->create(['key' => 'name', 'value' => $name]);
+ if ($collection) {
+ $collection->relations()->create(['related_id' => $col->id]);
+ }
+ $collection = $col;
+ DB::commit();
+ } else {
+ return null;
+ }
+ } else {
+ $collection = $found;
+ }
+ }
+
+ $folder->data['collection'] = $collection;
+
+ return $collection;
+ }
+}
diff --git a/src/app/DataMigrator/Driver/Kolab/Notes.php b/src/app/DataMigrator/Driver/Kolab/Notes.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Driver/Kolab/Notes.php
@@ -0,0 +1,290 @@
+<?php
+
+namespace App\DataMigrator\Driver\Kolab;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\Item;
+use App\Fs\Item as FsItem;
+use App\Support\Facades\Storage;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * Utilities to handle/migrate Kolab (v3 and v4) notes
+ */
+class Notes extends Fs
+{
+ /**
+ * Create a Kolab4 notes collection (folder)
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder object
+ */
+ public static function createFolder(Account $account, Folder $folder): void
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ self::getFsCollection($account, $folder, true, true);
+ }
+
+ /**
+ * Get note properties/content
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param Item $item File item
+ */
+ public static function fetchKolab3Note($imap, Item $item): void
+ {
+ $mailbox = $item->data['mailbox'];
+
+ $result = $imap->handlePartBody($mailbox, $item->data['uid'], true, 2, $item->data['encoding'], null, null);
+
+ if ($result === false) {
+ throw new \Exception("Failed to fetch IMAP message attachment for {$mailbox}/{$item->data['uid']}");
+ }
+
+ $doc = new \DOMDocument('1.0', 'UTF-8');
+ $doc->loadXML($result, \LIBXML_PARSEHUGE);
+
+ $summary = (string) $doc->getElementsByTagName('summary')->item(0)?->textContent;
+ $html = (string) $doc->getElementsByTagName('description')->item(0)?->textContent;
+
+ // In Kolab v3 notes can be also plain text, convert to HTML
+ if (!str_contains($html, '<')) {
+ $html = '<html><pre>' . $html . '</pre></html>';
+ }
+
+ $item->data['mimetype'] = 'text/html';
+ $item->data['displayname'] = $summary;
+
+ // Handle file content in memory (up to 20MB), bigger notes will use a temp file
+ if (strlen($html) > Engine::MAX_ITEM_SIZE) {
+ // Save the message content to a file
+ $location = $item->folder->tempFileLocation($item->id . '.html');
+
+ if (file_put_contents($location, $html) === false) {
+ throw new \Exception("Failed to write to a temp file at {$location}");
+ }
+
+ $item->filename = $location;
+ } else {
+ $item->content = $html;
+ }
+ }
+
+ /**
+ * Get notes from Kolab3 (IMAP) folder
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ * @param string $mailbox Folder name
+ * @param array $existing Files existing at the destination account
+ */
+ public static function getKolab3Notes($imap, $mailbox, $existing = []): array
+ {
+ // Find file objects
+ $search = 'NOT DELETED HEADER X-Kolab-Type "application/x-vnd.kolab.note"';
+ $search = $imap->search($mailbox, $search, true);
+ if ($search->is_empty()) {
+ return [];
+ }
+
+ $relations = Tags::getKolab3Relations($imap);
+
+ // TODO: Compare existing and migrated note regarding
+
+ // Get messages' basic headers, include headers for the XML attachment
+ // TODO: Limit data in FETCH, we need only INTERNALDATE, SIZE and SUBJECT.
+ $uids = $search->get_compressed();
+ $messages = $imap->fetchHeaders($mailbox, $uids, true, false, [], ['BODY.PEEK[2.MIME]']);
+ $notes = [];
+
+ foreach ($messages as $message) {
+ // Sanity check
+ if (empty($message->subject)) {
+ continue;
+ }
+
+ $mtime = \rcube_utils::anytodatetime($message->internaldate, new \DateTimeZone('UTC'));
+ $links = [];
+ $categories = [];
+
+ foreach ($relations as $relation) {
+ if (($found = array_search('urn:uuid:' . $message->subject, $relation['data']['member'])) !== false) {
+ if (!empty($relation['data']['name'])) {
+ $categories[] = $relation['data']['name'];
+ } else {
+ $members = $relation['data']['member'];
+ unset($members[$found]);
+ $links = array_merge($members, $links);
+ }
+ }
+ }
+
+ $links = array_values(array_unique($links));
+ $categories = array_values(array_unique($categories));
+
+ $exists = $existing[$message->subject] ?? null;
+ if ($exists && $exists->updated_at == $mtime
+ && $exists->links == $links
+ && $exists->categories == $categories
+ ) {
+ // No changes to the note, skip it
+ continue;
+ }
+
+ $headers = \rcube_mime::parse_headers($message->bodypart['2.MIME'] ?? '');
+
+ // Sanity check, part 2 is expected to be Kolab XML attachment
+ if (stripos($headers['content-type'] ?? '', 'application/vnd.kolab+xml') === false) {
+ continue;
+ }
+
+ // Note: We do not need to fetch and parse Kolab XML yet (we'll do it in fetch*())
+
+ $notes[] = [
+ 'id' => $message->subject,
+ 'existing' => $exists,
+ 'data' => [
+ 'mailbox' => $mailbox,
+ 'size' => $message->size,
+ 'mtime' => $mtime,
+ 'uid' => $message->uid,
+ 'encoding' => $headers['content-transfer-encoding'] ?? '8bit',
+ 'links' => $links,
+ 'categories' => $categories,
+ ],
+ ];
+ }
+
+ return $notes;
+ }
+
+ /**
+ * Get list of Kolab4 notes
+ *
+ * @param Account $account Destination account
+ * @param Folder $folder Folder
+ */
+ public static function getKolab4Notes(Account $account, Folder $folder): array
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ $collection = self::getFsCollection($account, $folder, false, true);
+
+ if (!$collection) {
+ return [];
+ }
+
+ return $collection->children()
+ ->select('fs_items.*')
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'name') as name"))
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'dav:links') as links"))
+ ->addSelect(DB::raw("(select value from fs_properties where fs_properties.item_id = fs_items.id"
+ . " and fs_properties.key = 'dav:categories') as categories"))
+ ->where('type', '&', FsItem::TYPE_FILE)
+ ->whereNot('type', '&', FsItem::TYPE_INCOMPLETE)
+ ->get()
+ ->keyBy(static function ($item, int $key) {
+ // Get UID from the filename
+ // @phpstan-ignore-next-line
+ return str_replace('.html', '', $item->name);
+ })
+ ->each(static function ($item) {
+ // @phpstan-ignore-next-line
+ $item->links = $item->links ? json_decode($item->links, true) : [];
+ // @phpstan-ignore-next-line
+ $item->categories = $item->categories ? json_decode($item->categories, true) : [];
+ })
+ ->all();
+ }
+
+ /**
+ * Save a file into Kolab4 storage
+ *
+ * @param Account $account Destination account
+ * @param Item $item File item
+ */
+ public static function saveKolab4Note(Account $account, Item $item): void
+ {
+ // We assume destination is the local server. Maybe we should be using Cockpit API?
+ $collection = self::getFsCollection($account, $item->folder, false, true);
+
+ if (!$collection) {
+ throw new \Exception("Failed to find destination collection for {$item->folder->fullname}");
+ }
+
+ $params = ['mimetype' => $item->data['mimetype']];
+
+ DB::beginTransaction();
+
+ if ($item->existing) {
+ /** @var FsItem $file */
+ $file = $item->existing;
+ $file->setProperties(self::noteProperties($item, true));
+ } else {
+ $file = new FsItem();
+ $file->user_id = $account->getUser()->id;
+ $file->type = FsItem::TYPE_FILE;
+ $file->save();
+
+ $properties = self::noteProperties($item);
+ $properties[] = ['key' => 'name', 'value' => $item->id . '.html'];
+
+ $file->properties()->createMany($properties);
+ $collection->relations()->create(['related_id' => $file->id]);
+ }
+
+ if ($item->filename) {
+ $fp = fopen($item->filename, 'r');
+ } else {
+ $fp = fopen('php://memory', 'r+');
+ fwrite($fp, $item->content);
+ rewind($fp);
+ }
+
+ Storage::fileInput($fp, $params, $file);
+
+ // Update the mtime, must be after fileInput() call
+ if (!empty($item->data['mtime'])) {
+ FsItem::where('id', $file->id)->update(['updated_at' => $item->data['mtime']]);
+ }
+
+ DB::commit();
+
+ fclose($fp);
+ }
+
+ /**
+ * Extract Kolab4 note properties from Kolab3 note data
+ */
+ protected static function noteProperties(Item $item, $key_value = false): array
+ {
+ $properties = [];
+
+ foreach (['displayname', 'links', 'categories'] as $prop) {
+ if (isset($item->data[$prop])) {
+ if (is_array($item->data[$prop])) {
+ if (empty($item->data[$prop])) {
+ if (!$key_value) {
+ continue;
+ }
+ $value = null;
+ } else {
+ $value = json_encode(array_values(array_unique($item->data[$prop])));
+ }
+ } else {
+ $value = $item->data[$prop];
+ }
+
+ if ($key_value) {
+ $properties["dav:{$prop}"] = $value;
+ } else {
+ $properties[] = ['key' => "dav:{$prop}", 'value' => $value];
+ }
+ }
+ }
+
+ return $properties;
+ }
+}
diff --git a/src/app/DataMigrator/Driver/Kolab/Tags.php b/src/app/DataMigrator/Driver/Kolab/Tags.php
--- a/src/app/DataMigrator/Driver/Kolab/Tags.php
+++ b/src/app/DataMigrator/Driver/Kolab/Tags.php
@@ -2,6 +2,7 @@
namespace App\DataMigrator\Driver\Kolab;
+use App\DataMigrator\Driver\Kolab as Driver;
use App\DataMigrator\Interface\Item;
/**
@@ -76,14 +77,70 @@
$item->data['member'] = $members;
}
+ /**
+ * Find the configuration folder in IMAP
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ */
+ protected static function findConfigurationFolder($imap): ?string
+ {
+ $meta_keys = [
+ Driver::CTYPE_KEY,
+ Driver::CTYPE_KEY_PRIVATE,
+ ];
+
+ $metadata = $imap->getMetadata('*', $meta_keys);
+
+ if ($metadata === null) {
+ throw new \Exception("Failed to get METADATA for IMAP folders. Not a Kolab server?");
+ }
+
+ foreach ($metadata as $folder => $meta) {
+ $type = 'mail';
+ if (!empty($meta[Driver::CTYPE_KEY_PRIVATE])) {
+ $type = $meta[Driver::CTYPE_KEY_PRIVATE];
+ } elseif (!empty($meta[Driver::CTYPE_KEY])) {
+ $type = $meta[Driver::CTYPE_KEY];
+ }
+
+ if (str_starts_with($type, 'configuration') && !preg_match('~(Shared Folders|Other Users)/.*~', $folder)) {
+ return $folder;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get Kolab3 relations (generic and tags)
+ *
+ * @param \rcube_imap_generic $imap IMAP client (account)
+ */
+ public static function getKolab3Relations($imap): array
+ {
+ // Cache this information as it can be invoked multiple times
+ if (isset($imap->data['RELATIONS'])) {
+ return $imap->data['RELATIONS'];
+ }
+
+ $folder = self::findConfigurationFolder($imap);
+
+ if ($folder === null) {
+ return $imap->data['RELATIONS'] = [];
+ }
+
+ return $imap->data['RELATIONS'] = self::getKolab3Tags($imap, $folder, [], null);
+ }
+
/**
* Get tags from Kolab3 folder
*
* @param \rcube_imap_generic $imap IMAP client (account)
* @param string $mailbox Configuration folder name
* @param array $existing Tags existing at the destination account
+ * @param ?string $type Relation type filter
*/
- public static function getKolab3Tags($imap, $mailbox, $existing = []): array
+ public static function getKolab3Tags($imap, $mailbox, $existing = [], $type = 'tag'): array
{
// Find relation objects
$search = 'NOT DELETED HEADER X-Kolab-Type "application/x-vnd.kolab.configuration.relation"';
@@ -129,7 +186,7 @@
}
}
- if ($tag['relationType'] === 'tag') {
+ if (empty($type) || $tag['relationType'] === $type) {
if (empty($tag['last-modification-date'])) {
$tag['last-modification-date'] = $message->internaldate;
}
diff --git a/src/app/Fs/Item.php b/src/app/Fs/Item.php
--- a/src/app/Fs/Item.php
+++ b/src/app/Fs/Item.php
@@ -28,6 +28,7 @@
public const TYPE_FILE = 1;
public const TYPE_COLLECTION = 2;
public const TYPE_INCOMPLETE = 4;
+ public const TYPE_NOTEBOOK = 8;
/** @var list<string> The attributes that are mass assignable */
protected $fillable = ['user_id', 'type'];
@@ -187,6 +188,14 @@
return (bool) ($this->type & self::TYPE_INCOMPLETE);
}
+ /**
+ * Check if the item is a notebook collection
+ */
+ public function isNotebook(): bool
+ {
+ return (bool) ($this->type & self::TYPE_NOTEBOOK);
+ }
+
/**
* Move the item to another location
*
@@ -305,4 +314,30 @@
{
return $this->belongsToMany(self::class, 'fs_relations', 'related_id', 'item_id');
}
+
+ /**
+ * Item type mutator
+ *
+ * @throws \Exception
+ */
+ public function setTypeAttribute($type)
+ {
+ if (!is_numeric($type)) {
+ throw new \Exception("Expecting an item type to be numeric");
+ }
+
+ $type = (int) $type;
+
+ if ($type < 0 || $type > 255) {
+ throw new \Exception("Expecting an item type between 0 and 255");
+ }
+
+ if ($type & self::TYPE_FILE) {
+ if ($type & self::TYPE_COLLECTION || $type & self::TYPE_NOTEBOOK) {
+ throw new \Exception("An item type cannot be file and collection at the same time");
+ }
+ }
+
+ $this->attributes['type'] = $type;
+ }
}
diff --git a/src/app/Http/Controllers/DAVController.php b/src/app/Http/Controllers/DAVController.php
--- a/src/app/Http/Controllers/DAVController.php
+++ b/src/app/Http/Controllers/DAVController.php
@@ -69,6 +69,7 @@
// Register some plugins
$server->addPlugin(new \Sabre\DAV\Auth\Plugin($auth_backend));
+ $server->addPlugin(new DAV\ServerPlugin());
// Unauthenticated access doesn't work for us since we require credentials to get access to the data in the first place.
// $acl_plugin = new \Sabre\DAVACL\Plugin();
diff --git a/src/app/Http/DAV/Collection.php b/src/app/Http/DAV/Collection.php
--- a/src/app/Http/DAV/Collection.php
+++ b/src/app/Http/DAV/Collection.php
@@ -8,15 +8,18 @@
use Sabre\DAV\Exception;
use Sabre\DAV\ICollection;
use Sabre\DAV\ICopyTarget;
+use Sabre\DAV\IExtendedCollection;
use Sabre\DAV\IMoveTarget;
use Sabre\DAV\INode;
use Sabre\DAV\INodeByPath;
use Sabre\DAV\IProperties;
+use Sabre\DAV\MkCol;
+use Sabre\DAV\Xml\Property\ResourceType;
/**
* Sabre DAV Collection interface implemetation
*/
-class Collection extends Node implements ICollection, ICopyTarget, IMoveTarget, INodeByPath, IProperties
+class Collection extends Node implements ICollection, ICopyTarget, IExtendedCollection, IMoveTarget, INodeByPath, IProperties
{
/**
* Checks if a child-node exists.
@@ -76,6 +79,50 @@
return true;
}
+ /**
+ * Creates a new collection.
+ *
+ * This method will receive a MkCol object with all the information about
+ * the new collection that's being created.
+ *
+ * The MkCol object contains information about the resourceType of the new
+ * collection. If you don't support the specified resourceType, you should
+ * throw Exception\InvalidResourceType.
+ *
+ * The object also contains a list of WebDAV properties for the new
+ * collection.
+ *
+ * You should call the handle() method on this object to specify exactly
+ * which properties you are storing. This allows the system to figure out
+ * exactly which properties you didn't store, which in turn allows other
+ * plugins (such as the propertystorage plugin) to handle storing the
+ * property for you.
+ *
+ * @param string $name
+ *
+ * @throws Exception\InvalidResourceType
+ */
+ public function createExtendedCollection($name, MkCol $mkCol)
+ {
+ $types = $mkCol->getResourceType();
+
+ if (count($types) > 1) {
+ // For now we only support use of 'notebook' in the resourcetype (Kolab Notes)
+ if (in_array('{Kolab:}notebook', $types)) {
+ $type = Item::TYPE_NOTEBOOK;
+ } else {
+ throw new Exception\InvalidResourceType('The {DAV:}resourcetype you specified is not supported here.');
+ }
+ }
+
+ $collection = $this->createDirectory($name);
+
+ if (!empty($type)) {
+ $collection->type |= $type;
+ $collection->save();
+ }
+ }
+
/**
* Creates a new file in the directory
*
@@ -160,6 +207,8 @@
}
DB::commit();
+
+ return $collection;
}
/**
@@ -176,7 +225,7 @@
// Delete the files/folders inside
// TODO: This may not be optimal for a case with a lot of files/folders
// TODO: Maybe deleting a folder contents should be moved to a delete event observer
- $this->data->children()->where('type', Item::TYPE_COLLECTION)
+ $this->data->children()->where('type', '&', Item::TYPE_COLLECTION)
->select('fs_items.*')
->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
. ' and fs_properties.key = \'name\') as name')
@@ -207,9 +256,10 @@
->select('fs_items.*')
->whereNot('type', '&', Item::TYPE_INCOMPLETE);
- foreach (['name', 'size', 'mimetype'] as $key) {
+ foreach (['name', 'size', 'mimetype', 'dav:displayname', 'dav:links', 'dav:categories'] as $key) {
+ $alias = str_replace('dav:', '', $key);
$query->selectRaw('(select value from fs_properties where fs_items.id = fs_properties.item_id'
- . " and fs_properties.key = '{$key}') as {$key}");
+ . " and fs_properties.key = '{$key}') as {$alias}");
}
if ($parent = $this->data) {
@@ -223,7 +273,7 @@
return $query->orderBy('name')
->get()
->map(function ($item) {
- $class = $item->type == Item::TYPE_COLLECTION ? Collection::class : File::class;
+ $class = $item->isCollection() ? Collection::class : File::class;
return new $class($this->nodePath($item), $this, $item);
})
->all();
@@ -274,7 +324,7 @@
$item = $this->fsItemForPath($path);
- $class = $item->type == Item::TYPE_COLLECTION ? self::class : File::class;
+ $class = $item->isCollection() ? self::class : File::class;
$parent = $this;
$parent_path = preg_replace('|/[^/]+$|', '', $path);
@@ -309,6 +359,10 @@
$result['{DAV:}creationdate'] = \Sabre\HTTP\toDate($this->data->created_at);
}
+ if ($this->data?->isNotebook()) {
+ $result['{DAV:}resourcetype'] = new ResourceType(['{DAV:}collection', '{Kolab:}notebook']);
+ }
+
return $result;
}
@@ -361,7 +415,6 @@
{
\Log::debug('[DAV] PROP-PATCH: ' . $this->path);
- // not supported
- // FIXME: Should we throw an exception?
+ // Not implemented
}
}
diff --git a/src/app/Http/DAV/File.php b/src/app/Http/DAV/File.php
--- a/src/app/Http/DAV/File.php
+++ b/src/app/Http/DAV/File.php
@@ -3,7 +3,7 @@
namespace App\Http\DAV;
use App\Backends\Storage;
-use Sabre\DAV\Exception;
+use App\Fs\Item;
use Sabre\DAV\IFile;
use Sabre\DAV\IProperties;
@@ -86,6 +86,18 @@
$result['{DAV:}creationdate'] = \Sabre\HTTP\toDate($this->data->created_at);
}
+ if (!empty($this->data->displayname)) {
+ $result['{DAV:}displayname'] = $this->data->displayname;
+ }
+
+ if (isset($this->data->links)) {
+ $result['{Kolab:}links'] = self::propListOutput(\json_decode($this->data->links), 'link');
+ }
+
+ if (isset($this->data->categories)) {
+ $result['{Kolab:}categories'] = self::propListOutput(\json_decode($this->data->categories), 'category');
+ }
+
return $result;
}
@@ -112,8 +124,62 @@
{
\Log::debug('[DAV] PROP-PATCH: ' . $this->path);
- // not supported
- // FIXME: Should we throw an exception?
+ // Note: Here we register handlers that are executed later by Sabre/DAV
+ $propPatch->handle(
+ // Properties used by Kolab Notes
+ ['{DAV:}displayname', '{Kolab:}links', '{Kolab:}categories'],
+ function ($properties) {
+ return $this->propPatchValidateAndSave($properties);
+ }
+ );
+ }
+
+ /**
+ * Validate PROPPATCH properties
+ */
+ protected function propPatchValidateAndSave($properties): array
+ {
+ $result = [];
+ $updated = false;
+
+ foreach ($properties as $key => $value) {
+ $status = true;
+ $prop_name = null;
+
+ switch ($key) {
+ case '{DAV:}displayname':
+ $prop_name = 'dav:displayname';
+ $status = is_string($value);
+ break;
+ case '{Kolab:}categories':
+ case '{Kolab:}links':
+ $prop_name = 'dav:' . str_replace('{Kolab:}', '', $key);
+ $status = is_array($value);
+ break;
+ }
+
+ if ($status && $prop_name) {
+ if ($value === '' || (is_array($value) && empty($value))) {
+ $value = null;
+ }
+ if (is_array($value)) {
+ $value = json_encode($value);
+ }
+
+ $updated = $updated || $value !== ($this->data->{$prop_name} ?? null);
+ $this->data->setProperty($prop_name, $value);
+ }
+
+ $result[$key] = $status ? 200 : 403; // result to SabreDAV
+ }
+
+ // Bump last modification time (needed e.g. for proper WebDAV syncronization/ETag)
+ // Note: We don't use touch() directly on $file because it fails when the object has custom properties
+ if ($updated) {
+ Item::where('id', $this->data->id)->touch();
+ }
+
+ return $result;
}
/**
diff --git a/src/app/Http/DAV/Locks.php b/src/app/Http/DAV/Locks.php
--- a/src/app/Http/DAV/Locks.php
+++ b/src/app/Http/DAV/Locks.php
@@ -33,6 +33,9 @@
{
\Log::debug('[DAV] GET-LOCKS: ' . $uri);
+ // TODO: On a node delete Sabre invokes this method twice (once before and once after)
+ // so there's a place for some optimization.
+
// Note: We're disabling exceptions here, otherwise it has unwanted effects
// in places where Sabre checks locks on non-existing paths
$ids = Node::resolvePath($uri, true);
diff --git a/src/app/Http/DAV/Node.php b/src/app/Http/DAV/Node.php
--- a/src/app/Http/DAV/Node.php
+++ b/src/app/Http/DAV/Node.php
@@ -212,11 +212,14 @@
$item = $query->first();
- // Get file properties
+ // Get file/folder properties
// TODO: In some requests context (e.g. LOCK/UNLOCK) we don't need these extra properties
- if ($item && $item->type == Item::TYPE_FILE) {
- $item->properties()->whereIn('key', ['size', 'mimetype'])->each(function ($prop) use ($item) {
- $item->{$prop->key} = $prop->value;
+ if ($item && $item->isFile()) {
+ $keys = ['size', 'mimetype', 'dav:displayname', 'dav:links', 'dav:categories'];
+
+ $item->properties()->whereIn('key', $keys)->each(function ($prop) use ($item) {
+ $key = str_replace('dav:', '', $prop->key);
+ $item->{$key} = $prop->value;
});
}
}
@@ -268,4 +271,20 @@
// that's why we store all lookup results including `false`.
Context::addHidden('fs:' . $path, $item);
}
+
+ /**
+ * Convert an array into XML property understood by the Sabre XML writer
+ */
+ protected static function propListOutput(array $list, string $item_name): array
+ {
+ foreach ($list as $idx => $item) {
+ $list[$idx] = [
+ 'name' => "{Kolab:}{$item_name}",
+ 'value' => $item,
+ 'properties' => [],
+ ];
+ }
+
+ return $list;
+ }
}
diff --git a/src/app/Http/DAV/ServerPlugin.php b/src/app/Http/DAV/ServerPlugin.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/DAV/ServerPlugin.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\DAV;
+
+use Sabre\DAV\Server;
+use Sabre\Xml\Deserializer;
+use Sabre\Xml\Reader;
+
+/**
+ * A plugin covering Kolab XML extensions.
+ *
+ * Plugins can modifies/extends the Sabre server behaviour.
+ */
+class ServerPlugin extends \Sabre\DAV\ServerPlugin
+{
+ /**
+ * This initializes the plugin.
+ *
+ * This function is called by Sabre\DAV\Server, after addPlugin is called.
+ */
+ public function initialize(Server $server)
+ {
+ // Tell the XML parser how to handle structured Kolab properties
+ $server->xml->elementMap['{Kolab:}links'] = function (Reader $reader) {
+ return Deserializer\repeatingElements($reader, '{Kolab:}link');
+ };
+
+ $server->xml->elementMap['{Kolab:}categories'] = function (Reader $reader) {
+ return Deserializer\repeatingElements($reader, '{Kolab:}category');
+ };
+ }
+}
diff --git a/src/tests/BackendsTrait.php b/src/tests/BackendsTrait.php
--- a/src/tests/BackendsTrait.php
+++ b/src/tests/BackendsTrait.php
@@ -16,6 +16,7 @@
Engine::TYPE_TASK => DAV::TYPE_VTODO,
Engine::TYPE_CONTACT => DAV::TYPE_VCARD,
Engine::TYPE_GROUP => DAV::TYPE_VCARD,
+ Engine::TYPE_NOTE => DAV::TYPE_NOTE,
];
/**
@@ -79,7 +80,7 @@
$dav_type = $this->davTypes[$type];
$home = $dav->getHome($dav_type);
$folder_id = Utils::uuidStr();
- $collection_type = $dav_type == DAV::TYPE_VCARD ? 'addressbook' : 'calendar';
+ $collection_type = DAV::collectionType($dav_type);
// We create all folders on the top-level
$folder = new DAV\Folder();
@@ -88,6 +89,12 @@
$folder->components = [$dav_type];
$folder->types = ['collection', $collection_type];
+ if ($type == Engine::TYPE_NOTE) {
+ $folder->components = [];
+ $folder->name = null;
+ $folder->href = rtrim($home, '/') . '/' . $foldername;
+ }
+
if ($dav->folderCreate($folder) === false) {
throw new \Exception("Failed to create folder {$account}/{$folder->href}");
}
@@ -160,17 +167,15 @@
$dav = $this->getDavClient($account);
- $search = new DAV\Search($this->davTypes[$type], true);
-
- $searchResult = $dav->search($folder->href, $search);
-
- if ($searchResult === false) {
- throw new \Exception("Failed to get items from a DAV folder {$account}/{$folder->href}");
+ if ($type == Engine::TYPE_NOTE) {
+ $result = $dav->listNotes($folder->href);
+ } else {
+ $search = new DAV\Search($this->davTypes[$type], true);
+ $result = $dav->search($folder->href, $search);
}
- $result = [];
- foreach ($searchResult as $item) {
- $result[] = $item;
+ if ($result === false) {
+ throw new \Exception("Failed to get items from a DAV folder {$account}/{$folder->href}");
}
return $result;
diff --git a/src/tests/Feature/Controller/DAVTest.php b/src/tests/Feature/Controller/DAVTest.php
--- a/src/tests/Feature/Controller/DAVTest.php
+++ b/src/tests/Feature/Controller/DAVTest.php
@@ -503,6 +503,23 @@
$this->assertCount(1, $children = $items[0]->children()->get());
$this->assertSame($john->id, $children[0]->user_id);
$this->assertSame('folder2', $children[0]->getProperty('name'));
+
+ // Kolab Notes folder
+ $xml = <<<'EOF'
+ <d:mkcol xmlns:d='DAV:' xmlns:k='Kolab:'>
+ <d:set>
+ <d:prop><d:resourcetype><d:collection/><k:notebook/></d:resourcetype></d:prop>
+ </d:set>
+ </d:mkcol>
+ EOF;
+
+ $response = $this->davRequest('MKCOL', "{$root}/folder1/notes", $xml, $john);
+ $response->assertNoContent(201);
+
+ $this->assertCount(1, $children = $items[0]->children()->whereNot('fs_items.id', $children[0]->id)->get());
+ $this->assertSame($john->id, $children[0]->user_id);
+ $this->assertSame('notes', $children[0]->getProperty('name'));
+ $this->assertTrue($children[0]->isNotebook());
}
/**
@@ -745,9 +762,26 @@
$this->assertCount(4, $responses = $doc->documentElement->getElementsByTagName('response'));
$this->assertSame("/{$root}/folder1/", $responses[0]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame(1, $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes->count());
+ $this->assertSame('collection', $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes->item(0)->localName);
$this->assertSame("/{$root}/folder1/folder2/", $responses[1]->getElementsByTagName('href')->item(0)->textContent);
$this->assertSame("/{$root}/folder1/test3.txt", $responses[2]->getElementsByTagName('href')->item(0)->textContent);
$this->assertSame("/{$root}/folder1/test4.txt", $responses[3]->getElementsByTagName('href')->item(0)->textContent);
+
+ // Test Kolab Notes folder property
+ $folders[0]->type |= Item::TYPE_NOTEBOOK;
+ $folders[0]->save();
+ $response = $this->davRequest('PROPFIND', "{$root}/folder1", '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>', $john, ['Depth' => 0]);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertCount(1, $responses = $doc->documentElement->getElementsByTagName('response'));
+
+ $this->assertSame("/{$root}/folder1/", $responses[0]->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame(2, $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes->count());
+ $this->assertSame('collection', $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes->item(0)->localName);
+ $this->assertSame('notebook', $responses[0]->getElementsByTagName('resourcetype')->item(0)->childNodes->item(1)->localName);
}
/**
@@ -855,7 +889,59 @@
$this->assertCount(1, $doc->getElementsByTagName('response'));
$this->assertSame('HTTP/1.1 403 Forbidden', $doc->getElementsByTagName('status')->item(0)->textContent);
- // Note: We don't support any properties in PROPPATCH yet
+ // Test Kolab Notes properties
+ $folder = $this->getTestCollection($john, 'notes');
+ $file = $this->getTestFile($john, 'test.html', '<html>Test con2</html>', ['mimetype' => 'text/html']);
+ $folder->children()->attach($file);
+
+ $xml = <<<'EOF'
+ <d:propertyupdate xmlns:d="DAV:" xmlns:k="Kolab:">
+ <d:set>
+ <d:prop>
+ <d:displayname>test note</d:displayname>
+ <k:categories>
+ <k:category>cat1</k:category>
+ <k:category>cat2</k:category>
+ </k:categories>
+ <k:links>
+ <k:link>imap:///test</k:link>
+ </k:links>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>
+ EOF;
+
+ $response = $this->davRequest('PROPPATCH', "{$root}/notes/test.html", $xml, $john);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+
+ // Use PROPFIND to check the properties' values
+ $xml = <<<'EOF'
+ <d:propfind xmlns:d="DAV:" xmlns:k="Kolab:">
+ <d:prop>
+ <d:displayname/>
+ <k:categories/>
+ <k:links/>
+ </d:prop>
+ </d:propfind>
+ EOF;
+
+ $response = $this->davRequest('PROPFIND', "{$root}/notes/test.html", $xml, $john, ['Depth' => '0']);
+ $response->assertStatus(207);
+
+ $doc = $this->responseXML($response);
+ $this->assertSame('multistatus', $doc->documentElement->localName);
+ $this->assertSame("/{$root}/notes/test.html", $doc->getElementsByTagName('href')->item(0)->textContent);
+ $this->assertSame(1, ($links = $doc->getElementsByTagName('links')->item(0))->childNodes->count());
+ $this->assertSame('imap:///test', $links->getElementsByTagName('link')->item(0)->textContent);
+ $this->assertSame(2, ($categories = $doc->getElementsByTagName('categories')->item(0))->childNodes->count());
+ $this->assertSame('cat1', $categories->getElementsByTagName('category')->item(0)->textContent);
+ $this->assertSame('cat2', $categories->getElementsByTagName('category')->item(1)->textContent);
+ $this->assertSame('test note', $doc->getElementsByTagName('displayname')->item(0)->textContent);
+
+ // TODO: Test changing/unsetting above properties
}
/**
diff --git a/src/tests/Feature/DataMigrator/KolabTest.php b/src/tests/Feature/DataMigrator/KolabTest.php
--- a/src/tests/Feature/DataMigrator/KolabTest.php
+++ b/src/tests/Feature/DataMigrator/KolabTest.php
@@ -164,8 +164,9 @@
->get()
->keyBy('name')
->all();
- $this->assertSame(2, count($folders));
- $this->assertSame(3, count($files));
+
+ $this->assertSame(4, count($folders));
+ $this->assertSame(6, count($files));
$this->assertArrayHasKey('A€B', $folders);
$this->assertArrayHasKey('Files', $folders);
$this->assertTrue($folders['Files']->children->contains($folders['A€B']));
@@ -184,6 +185,28 @@
$this->assertSame($file_content, Storage::fileFetch($files['test2.odt']));
$this->assertSame('', Storage::fileFetch($files['empty.txt']));
+ // Assert migrated notes
+ $this->assertArrayHasKey('Notes', $folders);
+ $this->assertArrayHasKey('Notes » Sub Notes', $folders);
+ $this->assertSame(0, $folders['Notes']->parents()->count());
+ $this->assertSame(0, $folders['Notes » Sub Notes']->parents()->count());
+ $this->assertTrue($folders['Notes']->isNotebook());
+ $this->assertTrue($folders['Notes » Sub Notes']->isNotebook());
+ $this->assertTrue($folders['Notes']->children->contains($files['111-111.html']));
+ $this->assertTrue($folders['Notes']->children->contains($files['222-222.html']));
+ $this->assertTrue($folders['Notes » Sub Notes']->children->contains($files['333-333.html']));
+ $this->assertSame('Note summary 1', $files['111-111.html']->getProperty('dav:displayname'));
+ $this->assertSame('Note summary 2', $files['222-222.html']->getProperty('dav:displayname'));
+ $this->assertSame('Note summary 3', $files['333-333.html']->getProperty('dav:displayname'));
+ $this->assertSame('text/html', $files['111-111.html']->getProperty('mimetype'));
+ $this->assertSame('<html><pre>plain text description</pre></html>', Storage::fileFetch($files['111-111.html']));
+ $this->assertSame('<html><body>html description X</body></html>', Storage::fileFetch($files['333-333.html']));
+ $this->assertSame('["tag"]', $files['111-111.html']->getProperty('dav:categories'));
+ $this->assertNull($files['222-222.html']->getProperty('dav:categories'));
+ $links = json_decode($files['111-111.html']->getProperty('dav:links'), true);
+ $this->assertCount(1, $links);
+ $this->assertStringStartsWith('imap:///user/ned%40kolab.org/INBOX', $links[0]);
+
self::$skipTearDown = true;
self::$skipSetUp = true;
}
@@ -214,6 +237,13 @@
$file_content = rtrim(chunk_split(base64_encode('123'), 76, "\r\n"));
$replaces = ['/%FILE%/' => $file_content];
$this->imapAppend($src_imap, 'Files', 'kolab3/file1.eml', [], '12-Jan-2024 09:09:09 +0000', $replaces);
+ $this->imapEmptyFolder($src_imap, 'Notes');
+ $replaces = [
+ '/<summary>.*<\/summary>/' => '<summary>mod</summary>',
+ '/<description>.*<\/description>/' => '<description>mod text</description>',
+ ];
+ $this->imapAppend($src_imap, 'Notes', 'kolab3/note1.eml', [], '12-Jan-2024 09:09:20 +0000', $replaces);
+ $this->imapAppend($src_imap, 'Notes', 'kolab3/note2.eml');
// Run the migration
$migrator = new Engine();
@@ -263,11 +293,17 @@
->get()
->keyBy('name')
->all();
- $this->assertSame(3, count($files));
+ $this->assertSame(6, count($files));
$this->assertSame(3, (int) $files['&kość.odt']->getProperty('size'));
$this->assertSame('application/vnd.oasis.opendocument.odt', $files['&kość.odt']->getProperty('mimetype'));
$this->assertSame('2024-01-12 09:09:09', $files['&kość.odt']->updated_at->toDateTimeString());
$this->assertSame('123', Storage::fileFetch($files['&kość.odt']));
+
+ // Assert notes
+ $this->assertSame('mod', $files['111-111.html']->getProperty('dav:displayname'));
+ $this->assertSame('<html><pre>mod text</pre></html>', Storage::fileFetch($files['111-111.html']));
+ $this->assertSame('["tag","test"]', $files['111-111.html']->getProperty('dav:categories'));
+ $this->assertSame('["test"]', $files['222-222.html']->getProperty('dav:categories'));
}
/**
@@ -355,6 +391,7 @@
}
$this->imapAppend($imap_account, 'Configuration', 'kolab3/tag1.eml');
$this->imapAppend($imap_account, 'Configuration', 'kolab3/tag2.eml', ['DELETED']);
+ $this->imapAppend($imap_account, 'Configuration', 'kolab3/relation1.eml');
// Create a non-mail folder, we'll assert that it was skipped in migration
$this->imapCreateFolder($imap_account, 'Test');
@@ -380,6 +417,8 @@
'Tasks' => 'task',
'Contacts' => 'contact.default',
'Files' => 'file.default',
+ 'Notes' => 'note.default',
+ 'Notes/Sub Notes' => 'note',
];
$folders[$utf7_folder] = 'file';
foreach ($folders as $name => $type) {
@@ -404,6 +443,13 @@
$replaces['/&ko%C5%9B%C4%87.odt/'] = 'test2.odt';
$this->imapAppend($imap_account, $utf7_folder, 'kolab3/file1.eml', [], '12-Jan-2024 09:09:09 +0000', $replaces);
+ // Insert some notes
+ $this->imapEmptyFolder($imap_account, 'Notes');
+ $this->imapEmptyFolder($imap_account, 'Notes/Sub Notes');
+ $this->imapAppend($imap_account, 'Notes', 'kolab3/note1.eml', [], '12-Jan-2024 09:09:09 +0000');
+ $this->imapAppend($imap_account, 'Notes', 'kolab3/note2.eml', [], '12-Jan-2024 09:09:10 +0000');
+ $this->imapAppend($imap_account, 'Notes/Sub Notes', 'kolab3/note3.eml', [], '12-Jan-2024 09:09:11 +0000');
+
// Insert some mail to migrate
$this->imapEmptyFolder($imap_account, 'INBOX');
$this->imapEmptyFolder($imap_account, 'Drafts');
diff --git a/src/tests/Unit/Fs/ItemTest.php b/src/tests/Unit/Fs/ItemTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Fs/ItemTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Tests\Unit\Fs;
+
+use App\Fs\Item;
+use Tests\TestCase;
+
+class ItemTest extends TestCase
+{
+ /**
+ * Test type mutator
+ */
+ public function testSetTypeAttribute(): void
+ {
+ $item = new Item();
+
+ $this->expectException(\Exception::class);
+ $item->type = -1;
+
+ $this->expectException(\Exception::class);
+ $item->type = 'abc'; // @phpstan-ignore-line
+
+ $item->type = Item::TYPE_INCOMPLETE;
+ $this->assertSame(Item::TYPE_INCOMPLETE, $item->type);
+
+ $item->type |= Item::TYPE_FILE;
+ $this->assertSame(Item::TYPE_INCOMPLETE | Item::TYPE_FILE, $item->type);
+
+ $this->expectException(\Exception::class);
+ $item->type |= Item::TYPE_COLLECTION;
+
+ $this->expectException(\Exception::class);
+ $item->type |= Item::TYPE_NOTEBOOK;
+ }
+
+ /**
+ * Test is*() methods
+ */
+ public function testIsMethods(): void
+ {
+ $item = new Item();
+
+ $this->assertFalse($item->isFile());
+ $this->assertFalse($item->isIncomplete());
+ $this->assertFalse($item->isCollection());
+ $this->assertFalse($item->isNotebook());
+
+ $item->type = Item::TYPE_INCOMPLETE;
+ $this->assertFalse($item->isFile());
+ $this->assertTrue($item->isIncomplete());
+ $this->assertFalse($item->isCollection());
+ $this->assertFalse($item->isNotebook());
+
+ $item->type = Item::TYPE_FILE;
+ $this->assertTrue($item->isFile());
+ $this->assertFalse($item->isIncomplete());
+ $this->assertFalse($item->isCollection());
+ $this->assertFalse($item->isNotebook());
+
+ $item->type = Item::TYPE_COLLECTION;
+ $this->assertFalse($item->isFile());
+ $this->assertFalse($item->isIncomplete());
+ $this->assertTrue($item->isCollection());
+ $this->assertFalse($item->isNotebook());
+
+ $item->type = Item::TYPE_COLLECTION | Item::TYPE_NOTEBOOK;
+ $this->assertFalse($item->isFile());
+ $this->assertFalse($item->isIncomplete());
+ $this->assertTrue($item->isCollection());
+ $this->assertTrue($item->isNotebook());
+ }
+}
diff --git a/src/tests/data/kolab3/note1.eml b/src/tests/data/kolab3/note1.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/note1.eml
@@ -0,0 +1,38 @@
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="=_d9d93114ba9e03cf3e7ae9d4e99ed6fc"
+From: alec@alec.pl
+To: alec@alec.pl
+Date: Tue, 09 Oct 2018 09:43:47 +0200
+X-Kolab-Type: application/x-vnd.kolab.note
+X-Kolab-Mime-Version: 3.0
+Subject: 111-111
+User-Agent: Kolab 16/Roundcube 1.6-git
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit http://www.kolab.org/
+
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8; name=kolab.xml
+Content-Disposition: attachment; filename=kolab.xml
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<note xmlns="http://kolab.org" version="3.0">
+ <uid>111-111</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.2</prodid>
+ <creation-date>2018-10-09T07:43:47Z</creation-date>
+ <last-modification-date>2018-10-09T07:43:47Z</last-modification-date>
+ <classification>PUBLIC</classification>
+ <summary>Note summary 1</summary>
+ <description>plain text description</description>
+ <color/>
+</note>
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc--
diff --git a/src/tests/data/kolab3/note2.eml b/src/tests/data/kolab3/note2.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/note2.eml
@@ -0,0 +1,38 @@
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="=_d9d93114ba9e03cf3e7ae9d4e99ed6fc"
+From: alec@alec.pl
+To: alec@alec.pl
+Date: Tue, 09 Oct 2018 09:43:47 +0200
+X-Kolab-Type: application/x-vnd.kolab.note
+X-Kolab-Mime-Version: 3.0
+Subject: 222-222
+User-Agent: Kolab 16/Roundcube 1.6-git
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit http://www.kolab.org/
+
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8; name=kolab.xml
+Content-Disposition: attachment; filename=kolab.xml
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<note xmlns="http://kolab.org" version="3.0">
+ <uid>222-222</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.2</prodid>
+ <creation-date>2020-10-09T07:43:47Z</creation-date>
+ <last-modification-date>2022-10-09T07:43:47Z</last-modification-date>
+ <classification>PUBLIC</classification>
+ <summary>Note summary 2</summary>
+ <description><!DOCTYPE HTML><html><body>html description</body></html></description>
+ <color/>
+</note>
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc--
diff --git a/src/tests/data/kolab3/note3.eml b/src/tests/data/kolab3/note3.eml
new file mode 100644
--- /dev/null
+++ b/src/tests/data/kolab3/note3.eml
@@ -0,0 +1,38 @@
+MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="=_d9d93114ba9e03cf3e7ae9d4e99ed6fc"
+From: alec@alec.pl
+To: alec@alec.pl
+Date: Tue, 09 Oct 2018 09:43:47 +0200
+X-Kolab-Type: application/x-vnd.kolab.note
+X-Kolab-Mime-Version: 3.0
+Subject: 333-333
+User-Agent: Kolab 16/Roundcube 1.6-git
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=ISO-8859-1
+
+This is a Kolab Groupware object. To view this object you will need an emai=
+l client that understands the Kolab Groupware format. For a list of such em=
+ail clients please visit http://www.kolab.org/
+
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc
+Content-Transfer-Encoding: 8bit
+Content-Type: application/vnd.kolab+xml; charset=UTF-8; name=kolab.xml
+Content-Disposition: attachment; filename=kolab.xml
+
+<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
+<note xmlns="http://kolab.org" version="3.0">
+ <uid>333-333</uid>
+ <prodid>Roundcube-libkolab-1.1 Libkolabxml-1.2</prodid>
+ <creation-date>2020-10-09T07:43:47Z</creation-date>
+ <last-modification-date>2022-10-09T07:43:47Z</last-modification-date>
+ <classification>PUBLIC</classification>
+ <summary>Note summary 3</summary>
+ <description><html><body>html description X</body></html></description>
+ <color/>
+</note>
+
+--=_d9d93114ba9e03cf3e7ae9d4e99ed6fc--
diff --git a/src/tests/data/kolab3/tag1.eml b/src/tests/data/kolab3/relation1.eml
copy from src/tests/data/kolab3/tag1.eml
copy to src/tests/data/kolab3/relation1.eml
--- a/src/tests/data/kolab3/tag1.eml
+++ b/src/tests/data/kolab3/relation1.eml
@@ -4,7 +4,7 @@
Date: Fri, 10 Jan 2025 15:24:06 +0100
X-Kolab-Type: application/x-vnd.kolab.configuration.relation
X-Kolab-Mime-Version: 3.0
-Subject: a597dfc8-9876-4b1d-964d-f4fafc6f4481
+Subject: relation-generic1
User-Agent: Kolab 16/Roundcube 1.5-git
Content-Type: multipart/mixed;
boundary="=_9abdaa1a17f700a3819efeb6c73ee9fa"
@@ -17,30 +17,22 @@
l client that understands the Kolab Groupware format. For a list of such em=
ail clients please visit https://www.kolab.org/
-
--=_9abdaa1a17f700a3819efeb6c73ee9fa
Content-Transfer-Encoding: 8bit
-Content-Type: application/vnd.kolab+xml; charset=UTF-8;
- name=kolab.xml
-Content-Disposition: attachment;
- filename=kolab.xml;
- size=869
+Content-Type: application/vnd.kolab+xml; charset=UTF-8; name=kolab.xml
+Content-Disposition: attachment; filename=kolab.xml
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<configuration xmlns="http://kolab.org" version="3.0">
- <uid>a597dfc8-9876-4b1d-964d-f4fafc6f4481</uid>
+ <uid>relation-generic1</uid>
<prodid>Roundcube-libkolab-1.1 Libkolabxml-1.3.1</prodid>
<creation-date>2024-10-01T07:40:45Z</creation-date>
<last-modification-date>2025-01-10T14:24:06Z</last-modification-date>
<type>relation</type>
- <name>tag</name>
- <relationType>tag</relationType>
- <color>#E0431B</color>
- <priority>-1444709376</priority>
- <member>urn:uuid:ccc-ccc</member>
- <member>urn:uuid:ddd-ddd</member>
- <member>imap:///user/ned%40kolab.org/INBOX/10?message-id=%3Csync1%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync</member>
+ <name/>
+ <relationType>generic</relationType>
<member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+with+attachment</member>
+ <member>urn:uuid:111-111</member>
</configuration>
--=_9abdaa1a17f700a3819efeb6c73ee9fa--
diff --git a/src/tests/data/kolab3/tag1.eml b/src/tests/data/kolab3/tag1.eml
--- a/src/tests/data/kolab3/tag1.eml
+++ b/src/tests/data/kolab3/tag1.eml
@@ -39,6 +39,7 @@
<priority>-1444709376</priority>
<member>urn:uuid:ccc-ccc</member>
<member>urn:uuid:ddd-ddd</member>
+ <member>urn:uuid:111-111</member>
<member>imap:///user/ned%40kolab.org/INBOX/10?message-id=%3Csync1%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync</member>
<member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+with+attachment</member>
</configuration>
diff --git a/src/tests/data/kolab3/tag2.eml b/src/tests/data/kolab3/tag2.eml
--- a/src/tests/data/kolab3/tag2.eml
+++ b/src/tests/data/kolab3/tag2.eml
@@ -38,6 +38,8 @@
<color>#FFFFFF</color>
<priority>1</priority>
<member>urn:uuid:ccc-ccc</member>
+ <member>urn:uuid:111-111</member>
+ <member>urn:uuid:222-222</member>
<member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+with+attachment</member>
<member>imap:///user/ned%40kolab.org/Drafts/120?message-id=%3Csync3%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+migrator</member>
<member>imap:///user/ned%40kolab.org/Drafts/121?message-id=%3Csync4%40kolab.org%3E&date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&subject=test+sync+4th</member>
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 3:41 AM (14 h, 35 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822452
Default Alt Text
D5835.1775187705.diff (77 KB)
Attached To
Mode
D5835: WebDAV: Kolab Notes support
Attached
Detach File
Event Timeline