Page MenuHomePhorge

D5835.1775187710.diff
No OneTemporary

Authored By
Unknown
Size
73 KB
Referenced Files
None
Subscribers
None

D5835.1775187710.diff

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>&lt;!DOCTYPE HTML&gt;&lt;html&gt;&lt;body&gt;html description&lt;/body&gt;&lt;/html&gt;</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>&lt;html&gt;&lt;body&gt;html description X&lt;/body&gt;&lt;/html&gt;</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&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;subject=test+sync</member>
+ <name/>
+ <relationType>generic</relationType>
<member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;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&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;subject=test+sync</member>
<member>imap:///user/ned%40kolab.org/INBOX/11?message-id=%3Csync2%40kolab.org%3E&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;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&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;subject=test+sync+with+attachment</member>
<member>imap:///user/ned%40kolab.org/Drafts/120?message-id=%3Csync3%40kolab.org%3E&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;subject=test+migrator</member>
<member>imap:///user/ned%40kolab.org/Drafts/121?message-id=%3Csync4%40kolab.org%3E&amp;date=Thu%2C+09+Aug+2012+13%3A18%3A31+%2B0000&amp;subject=test+sync+4th</member>

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 3:41 AM (14 h, 34 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822455
Default Alt Text
D5835.1775187710.diff (73 KB)

Event Timeline