Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117730473
D4824.1774895439.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
75 KB
Referenced Files
None
Subscribers
None
D4824.1774895439.diff
View Options
diff --git a/src/.gitignore b/src/.gitignore
--- a/src/.gitignore
+++ b/src/.gitignore
@@ -25,5 +25,4 @@
resources/countries.php
resources/build/js/
database/seeds/
-src/public/themes/active
cache
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
@@ -9,6 +9,7 @@
public const TYPE_VEVENT = 'VEVENT';
public const TYPE_VTODO = 'VTODO';
public const TYPE_VCARD = 'VCARD';
+ public const TYPE_NOTIFICATION = 'NOTIFICATION';
protected const NAMESPACES = [
self::TYPE_VEVENT => 'urn:ietf:params:xml:ns:caldav',
@@ -20,13 +21,14 @@
protected $user;
protected $password;
protected $responseHeaders = [];
+ protected $homes;
/**
* Object constructor
*/
- public function __construct($user, $password)
+ public function __construct($user, $password, $url = null)
{
- $this->url = \config('services.dav.uri');
+ $this->url = $url ?: \config('services.dav.uri');
$this->user = $user;
$this->password = $password;
}
@@ -34,23 +36,13 @@
/**
* Discover DAV home (root) collection of a specified type.
*
- * @param string $component Component to filter by (VEVENT, VTODO, VCARD)
- *
- * @return string|false Home collection location or False on error
+ * @return array|false Home locations or False on error
*/
- public function discover(string $component = self::TYPE_VEVENT)
+ public function discover()
{
- $roots = [
- self::TYPE_VEVENT => 'calendars',
- self::TYPE_VTODO => 'calendars',
- self::TYPE_VCARD => 'addressbooks',
- ];
-
- $homes = [
- self::TYPE_VEVENT => 'calendar-home-set',
- self::TYPE_VTODO => 'calendar-home-set',
- self::TYPE_VCARD => 'addressbook-home-set',
- ];
+ if (is_array($this->homes)) {
+ return $this->homes;
+ }
$path = parse_url($this->url, PHP_URL_PATH);
@@ -62,64 +54,88 @@
. '</d:propfind>';
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
- $headers = ['Depth' => 1, 'Prefer' => 'return-minimal'];
-
- $response = $this->request('/' . $roots[$component], 'PROPFIND', $body, $headers);
+ $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
- \Log::error("Failed to get current-user-principal for {$component} from the DAV server.");
+ \Log::error("Failed to get current-user-principal from the DAV server.");
return false;
}
$elements = $response->getElementsByTagName('response');
+ $principal_href = '';
foreach ($elements as $element) {
- foreach ($element->getElementsByTagName('prop') as $prop) {
+ foreach ($element->getElementsByTagName('current-user-principal') as $prop) {
$principal_href = $prop->nodeValue;
break;
}
}
- if (empty($principal_href)) {
- \Log::error("No principal on the DAV server.");
- return false;
- }
-
- if ($path && strpos($principal_href, $path) === 0) {
+ if ($path && str_starts_with($principal_href, $path)) {
$principal_href = substr($principal_href, strlen($path));
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:propfind xmlns:d="DAV:" xmlns:c="' . self::NAMESPACES[$component] . '">'
+ . '<d:propfind xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav">'
. '<d:prop>'
- . '<c:' . $homes[$component] . ' />'
+ . '<cal:calendar-home-set/>'
+ . '<card:addressbook-home-set/>'
+ . '<d:notification-URL/>'
. '</d:prop>'
. '</d:propfind>';
$response = $this->request($principal_href, 'PROPFIND', $body);
if (empty($response)) {
- \Log::error("Failed to get homes for {$component} from the DAV server.");
+ \Log::error("Failed to get home collections from the DAV server.");
return false;
}
- $root_href = false;
$elements = $response->getElementsByTagName('response');
+ $homes = [];
- foreach ($elements as $element) {
- foreach ($element->getElementsByTagName('prop') as $prop) {
- $root_href = $prop->nodeValue;
- break;
+ if ($element = $response->getElementsByTagName('response')->item(0)) {
+ if ($prop = $element->getElementsByTagName('prop')->item(0)) {
+ foreach ($prop->childNodes as $home) {
+ if ($home->firstChild && $home->firstChild->localName == 'href') {
+ $href = $home->firstChild->nodeValue;
+
+ if ($path && str_starts_with($href, $path)) {
+ $href = substr($href, strlen($path));
+ }
+
+ $homes[$home->localName] = $href;
+ }
+ }
}
}
- if (!empty($root_href)) {
- if ($path && strpos($root_href, $path) === 0) {
- $root_href = substr($root_href, strlen($path));
- }
+ return $this->homes = $homes;
+ }
+
+ /**
+ * Get user home folder of specified type
+ *
+ * @param string $type Home type or component name
+ *
+ * @return string|null Folder location href
+ */
+ public function getHome($type)
+ {
+ $options = [
+ self::TYPE_VEVENT => 'calendar-home-set',
+ self::TYPE_VTODO => 'calendar-home-set',
+ self::TYPE_VCARD => 'addressbook-home-set',
+ self::TYPE_NOTIFICATION => 'notification-URL',
+ ];
+
+ $homes = $this->discover();
+
+ if (is_array($homes) && isset($options[$type])) {
+ return $homes[$options[$type]] ?? null;
}
- return $root_href;
+ return null;
}
/**
@@ -142,9 +158,9 @@
*/
public function listFolders(string $component)
{
- $root_href = $this->discover($component);
+ $root_href = $this->getHome($component);
- if ($root_href === false) {
+ if ($root_href === null) {
return false;
}
@@ -209,7 +225,8 @@
$response = $this->request($object->href, 'PUT', $object, $headers);
if ($response !== false) {
- if ($etag = $this->responseHeaders['etag']) {
+ if (!empty($this->responseHeaders['ETag'])) {
+ $etag = $this->responseHeaders['ETag'][0];
if (preg_match('|^".*"$|', $etag)) {
$etag = substr($etag, 1, -1);
}
@@ -314,6 +331,22 @@
return $response !== false;
}
+ /**
+ * Check server options (and authentication)
+ *
+ * @return false|array DAV capabilities on success, False on error
+ */
+ public function options()
+ {
+ $response = $this->request('', 'OPTIONS');
+
+ if ($response !== false) {
+ return preg_split('/,\s+/', implode(',', $this->responseHeaders['DAV'] ?? []));
+ }
+
+ return false;
+ }
+
/**
* Search DAV objects in a folder.
*
@@ -430,7 +463,7 @@
{
$doc = new \DOMDocument('1.0', 'UTF-8');
- if (stripos($xml, '<?xml') === 0) {
+ if (str_starts_with($xml, '<?xml')) {
if (!$doc->loadXML($xml)) {
throw new \Exception("Failed to parse XML");
}
@@ -456,7 +489,11 @@
$head .= "{$header_name}: {$header_value}\n";
}
- if (stripos($body, '<?xml') === 0) {
+ if ($body instanceof DAV\CommonObject) {
+ $body = (string) $body;
+ }
+
+ if (str_starts_with($body, '<?xml')) {
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
@@ -469,7 +506,7 @@
$body = $doc->saveXML();
}
- return $head . "\n" . rtrim($body);
+ return $head . (is_string($body) && strlen($body) > 0 ? "\n{$body}" : '');
}
/**
@@ -504,7 +541,7 @@
$this->responseHeaders = [];
- if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) {
+ if ($path && ($rootPath = parse_url($url, PHP_URL_PATH)) && str_starts_with($path, $rootPath)) {
$path = substr($path, strlen($rootPath));
}
@@ -526,7 +563,8 @@
}
if ($debug) {
- \Log::debug("C: {$method}: {$url}\n" . $this->debugBody($body, $headers));
+ $body = $this->debugBody($body, $headers);
+ \Log::debug("C: {$method}: {$url}" . (strlen($body) > 0 ? "\n$body" : ''));
}
$response = $client->send($method, $url);
diff --git a/src/app/Backends/DAV/Opaque.php b/src/app/Backends/DAV/Opaque.php
new file mode 100644
--- /dev/null
+++ b/src/app/Backends/DAV/Opaque.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Backends\DAV;
+
+class Opaque extends CommonObject
+{
+ protected $content;
+
+ public function __construct($filename)
+ {
+ $this->content = file_get_contents($filename);
+ }
+
+ /**
+ * Create string representation of the DAV object
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->content;
+ }
+}
diff --git a/src/app/Console/Commands/Data/MigrateCommand.php b/src/app/Console/Commands/Data/MigrateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Data/MigrateCommand.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Console\Commands\Data;
+
+use App\DataMigrator;
+
+use Illuminate\Console\Command;
+
+/**
+ * Migrate user data from an external service to Kolab.
+ *
+ * Example usage:
+ *
+ * ```
+ * php artisan data:migrate \
+ * "ews://$user:$pass@$server?client_id=$client_id&client_secret=$client_secret&tenant_id=$tenant_id" \
+ * "dav://$dest_user:$dest_pass@$dest_server"
+ * ```
+ */
+class MigrateCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'data:migrate
+ {src : Source account}
+ {dst : Destination account}
+ {--type= : Object type(s)}
+ {--force : Force existing queue removal}';
+// {--export-only : Only export data}
+// {--import-only : Only import previously exported data}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Migrate user data from an external service';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $src = new DataMigrator\Account($this->argument('src'));
+ $dst = new DataMigrator\Account($this->argument('dst'));
+ $options = [
+ 'type' => $this->option('type'),
+ 'force' => $this->option('force'),
+ 'stdout' => true,
+ ];
+
+ $migrator = new DataMigrator\Engine();
+ $migrator->migrate($src, $dst, $options);
+ }
+}
diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Account.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace App\DataMigrator;
+
+/**
+ * Data object representing user account on an external service
+ */
+class Account
+{
+ /** @var string User name (login) */
+ public $username;
+
+ /** @var string User password */
+ public $password;
+
+ /** @var string User email address */
+ public $email;
+
+ /** @var string Hostname */
+ public $host;
+
+ /** @var string Connection scheme (service type) */
+ public $scheme;
+
+ /** @var string Full account location URI (w/o parameters) */
+ public $uri;
+
+ /** @var string Username for proxy auth */
+ public $loginas;
+
+ /** @var array Additional parameters from the input */
+ public $params;
+
+ /** @var string Full account definition */
+ protected $input;
+
+
+ /**
+ * Object constructor
+ *
+ * Input can be a valid URI or "<username>:<password>".
+ * For proxy authentication use: "<proxy-user>**<username>" as username.
+ *
+ * @param string $input Account specification
+ */
+ public function __construct(string $input)
+ {
+ $url = parse_url($input);
+
+ // Not valid URI, try the other form of input
+ if ($url === false || !array_key_exists('scheme', $url)) {
+ list($user, $password) = explode(':', $input, 2);
+ $url = ['user' => $user, 'pass' => $password];
+ }
+
+ if (isset($url['user'])) {
+ $this->username = urldecode($url['user']);
+
+ if (strpos($this->username, '**')) {
+ list($this->username, $this->loginas) = explode('**', $this->username, 2);
+ }
+ }
+
+ if (isset($url['pass'])) {
+ $this->password = urldecode($url['pass']);
+ }
+
+ if (isset($url['scheme'])) {
+ $this->scheme = strtolower($url['scheme']);
+ }
+
+ if (isset($url['host'])) {
+ $this->host = $url['host'];
+ $this->uri = $this->scheme . '://' . $url['host'] . ($url['path'] ?? '');
+ }
+
+ if (!empty($url['query'])) {
+ parse_str($url['query'], $this->params);
+ }
+
+ if (strpos($this->loginas, '@')) {
+ $this->email = $this->loginas;
+ } elseif (strpos($this->username, '@')) {
+ $this->email = $this->username;
+ }
+
+ $this->input = $input;
+ }
+
+ /**
+ * Returns string representation of the object.
+ * You can use the result as an input to the object constructor.
+ *
+ * @return string Account string representation
+ */
+ public function __toString(): string
+ {
+ return $this->input;
+ }
+}
diff --git a/src/app/DataMigrator/DAV.php b/src/app/DataMigrator/DAV.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/DAV.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\Backends\DAV as DAVClient;
+use App\Backends\DAV\Opaque as DAVOpaque;
+use App\Backends\DAV\Folder as DAVFolder;
+use App\DataMigrator\Interface\Folder;
+use App\Utils;
+
+class DAV implements Interface\ImporterInterface
+{
+ /** @var DAVClient DAV Backend */
+ protected $client;
+
+ /** @var Account Account to operate on */
+ protected $account;
+
+ /** @var Engine Data migrator engine */
+ protected $engine;
+
+ /** @var array Settings */
+ protected $settings;
+
+
+ /**
+ * Object constructor
+ */
+ public function __construct(Account $account, Engine $engine)
+ {
+ $username = $account->username . ($account->loginas ? "**{$account->loginas}" : '');
+ $baseUri = rtrim($account->uri, '/');
+ $baseUri = preg_replace('|^dav|', 'http', $baseUri);
+
+ $this->settings = [
+ 'baseUri' => $baseUri,
+ 'userName' => $username,
+ 'password' => $account->password,
+ ];
+
+ $this->client = new DAVClient($username, $account->password, $baseUri);
+ $this->engine = $engine;
+ $this->account = $account;
+ }
+
+ /**
+ * Check user credentials.
+ *
+ * @throws \Exception
+ */
+ public function authenticate()
+ {
+ try {
+ $result = $this->client->options();
+ } catch (\Exception $e) {
+ throw new \Exception("Invalid DAV credentials or server.");
+ }
+ }
+
+ /**
+ * Create an item in a folder.
+ *
+ * @param string $filename File location
+ * @param Folder $folder Folder
+ *
+ * @throws \Exception
+ */
+ public function createItemFromFile(string $filename, Folder $folder): void
+ {
+ $href = $this->getFolderPath($folder) . '/' . pathinfo($filename, PATHINFO_BASENAME);
+
+ $object = new DAVOpaque($filename);
+ $object->href = $href;
+
+ switch ($folder->type) {
+ case Engine::TYPE_EVENT:
+ case Engine::TYPE_TASK:
+ $object->contentType = 'text/calendar; charset=utf-8';
+ break;
+
+ case Engine::TYPE_CONTACT:
+ $object->contentType = 'text/vcard; charset=utf-8';
+ break;
+ }
+
+ if ($this->client->create($object) === false) {
+ throw new \Exception("Failed to save object into DAV server at {$href}");
+ }
+ }
+
+ /**
+ * Create a folder.
+ *
+ * @param Folder $folder Folder data
+ *
+ * @throws \Exception on error
+ */
+ public function createFolder(Folder $folder): void
+ {
+ $dav_type = $this->type2DAV($folder->type);
+ $folders = $this->client->listFolders($dav_type);
+
+ if ($folders === false) {
+ throw new \Exception("Failed to list folders on the DAV server");
+ }
+
+ // Note: iRony flattens the list by modifying the folder name
+ // This is not going to work with Cyrus DAV, but anyway folder
+ // hierarchies support is not full in Kolab 4.
+ foreach ($folders as $dav_folder) {
+ if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) {
+ // do nothing, folder already exists
+ return;
+ }
+ }
+
+ $home = $this->client->getHome($dav_type);
+ $folder_id = Utils::uuidStr();
+ $collection_type = $dav_type == DAVClient::TYPE_VCARD ? 'addressbook' : 'calendar';
+
+ // We create all folders on the top-level
+ $dav_folder = new DAVFolder();
+ $dav_folder->name = $folder->fullname;
+ $dav_folder->href = rtrim($home, '/') . '/' . $folder_id;
+ $dav_folder->components = [$dav_type];
+ $dav_folder->types = ['collection', $collection_type];
+
+ if ($this->client->folderCreate($dav_folder) === false) {
+ throw new \Exception("Failed to create a DAV folder {$dav_folder->href}");
+ }
+ }
+
+ /**
+ * Get folder relative URI
+ */
+ protected function getFolderPath(Folder $folder): string
+ {
+ $folders = $this->client->listFolders($this->type2DAV($folder->type));
+
+ if ($folders === false) {
+ throw new \Exception("Failed to list folders on the DAV server");
+ }
+
+ // Note: iRony flattens the list by modifying the folder name
+ // This is not going to work with Cyrus DAV, but anyway folder
+ // hierarchies support is not full in Kolab 4.
+ foreach ($folders as $dav_folder) {
+ if (str_replace(' » ', '/', $dav_folder->name) === $folder->fullname) {
+ return rtrim($dav_folder->href, '/');
+ }
+ }
+
+ throw new \Exception("Folder not found: {$folder->fullname}");
+ }
+
+ /**
+ * Map Kolab type into DAV object type
+ */
+ protected static function type2DAV(string $type): string
+ {
+ switch ($type) {
+ case Engine::TYPE_EVENT:
+ return DAVClient::TYPE_VEVENT;
+ case Engine::TYPE_TASK:
+ return DAVClient::TYPE_VTODO;
+ case Engine::TYPE_CONTACT:
+ case Engine::TYPE_GROUP:
+ return DAVClient::TYPE_VCARD;
+ default:
+ throw new \Exception("Cannot map type '{$type}' to DAV");
+ }
+ }
+}
diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS.php
@@ -0,0 +1,422 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\Item;
+use garethp\ews\API;
+use garethp\ews\API\Type;
+use Illuminate\Support\Facades\Http;
+
+/**
+ * Data migration from Exchange (EWS)
+ */
+class EWS implements Interface\ExporterInterface
+{
+ /** @var API EWS API object */
+ public $api;
+
+ /** @var array Supported folder types */
+ protected $folder_classes = [
+ EWS\Appointment::FOLDER_TYPE,
+ EWS\Contact::FOLDER_TYPE,
+ EWS\Task::FOLDER_TYPE,
+ // TODO: mail and sticky notes are exported as eml files.
+ // We could use imapsync to synchronize mail, but for notes
+ // the only option will be to convert them to Kolab format here
+ // and upload to Kolab via IMAP, I guess
+ // EWS\Note::FOLDER_TYPE,
+ // EWS\StickyNote::FOLDER_TYPE,
+ ];
+
+ /** @var array Interal folders to skip */
+ protected $folder_exceptions = [
+ 'AllContacts',
+ 'AllPersonMetadata',
+ 'AllTodoTasks',
+ 'Document Centric Conversations',
+ 'ExternalContacts',
+ 'Favorites',
+ 'Flagged Emails',
+ 'GraphFilesAndWorkingSetSearchFolder',
+ 'My Contacts',
+ 'MyContactsExtended',
+ 'Orion Notes',
+ 'Outbox',
+ 'PersonMetadata',
+ 'People I Know',
+ 'RelevantContacts',
+ 'SharedFilesSearchFolder',
+ 'Sharing',
+ 'To-Do Search',
+ 'UserCuratedContacts',
+ 'XrmActivityStreamSearch',
+ 'XrmCompanySearch',
+ 'XrmDealSearch',
+ 'XrmSearch',
+ 'Folder Memberships',
+ // TODO: These are different depending on a user locale
+ 'Calendar/United States holidays',
+ 'Kalendarz/Polska — dni wolne od pracy', // pl
+ ];
+
+ /** @var array Map of EWS folder types to Kolab types */
+ protected $type_map = [
+ EWS\Appointment::FOLDER_TYPE => Engine::TYPE_EVENT,
+ EWS\Contact::FOLDER_TYPE => Engine::TYPE_CONTACT,
+ EWS\Task::FOLDER_TYPE => Engine::TYPE_TASK,
+ ];
+
+ /** @var Account Account to operate on */
+ protected $account;
+
+ /** @var Engine Data migrator engine */
+ protected $engine;
+
+
+ /**
+ * Object constructor
+ */
+ public function __construct(Account $account, Engine $engine)
+ {
+ $this->account = $account;
+ $this->engine = $engine;
+ }
+
+ /**
+ * Server autodiscovery
+ */
+ public static function autodiscover(string $user, string $password): ?string
+ {
+ // You should never run the Autodiscover more than once.
+ // It can make between 1 and 5 calls before giving up, or before finding your server,
+ // depending on how many different attempts it needs to make.
+
+ // TODO: Autodiscovery may fail with an exception thrown. Handle this nicely.
+ // TODO: Looks like this autodiscovery also does not work w/Basic Auth?
+
+ $api = API\ExchangeAutodiscover::getAPI($user, $password);
+
+ $server = $api->getClient()->getServer();
+ $version = $api->getClient()->getVersion();
+
+ return sprintf('ews://%s:%s@%s', urlencode($user), urlencode($password), $server);
+ }
+
+ /**
+ * Authenticate to EWS (initialize the EWS client)
+ */
+ public function authenticate()
+ {
+ if (!empty($this->account->params['client_id'])) {
+ $this->api = $this->authenticateWithOAuth2(
+ $this->account->host,
+ $this->account->username,
+ $this->account->params['client_id'],
+ $this->account->params['client_secret'],
+ $this->account->params['tenant_id']
+ );
+ } else {
+ // Note: This initializes the client, but not yet connects to the server
+ // TODO: To know that the credentials work we'll have to do some API call.
+ $this->api = $this->authenticateWithPassword(
+ $this->account->host,
+ $this->account->username,
+ $this->account->password,
+ $this->account->loginas
+ );
+ }
+ }
+
+ /**
+ * Autodiscover the server and authenticate the user
+ */
+ protected function authenticateWithPassword(string $server, string $user, string $password, string $loginas = null)
+ {
+ // Note: Since 2023-01-01 EWS at Office365 requires OAuth2, no way back to basic auth.
+
+ \Log::debug("[EWS] Using basic authentication on $server...");
+
+ $options = [];
+
+ if ($loginas) {
+ $options['impersonation'] = $loginas;
+ }
+
+ $this->engine->setOption('ews', [
+ 'options' => $options,
+ 'server' => $server,
+ ]);
+
+ return API::withUsernameAndPassword($server, $user, $password, $this->apiOptions($options));
+ }
+
+ /**
+ * Authenticate with a token (Office365)
+ */
+ protected function authenticateWithToken(string $server, string $user, string $token, $expires_at = null)
+ {
+ \Log::debug("[EWS] Using token authentication on $server...");
+
+ $options = ['impersonation' => $user];
+
+ $this->engine->setOption('ews', [
+ 'options' => $options,
+ 'server' => $server,
+ 'token' => $token,
+ 'expires_at' => $expires_at,
+ ]);
+
+ return API::withCallbackToken($server, $token, $this->apiOptions($options));
+ }
+
+ /**
+ * Authenticate with OAuth2 (Office365) - get the token
+ */
+ protected function authenticateWithOAuth2(string $server,
+ string $user, string $client_id, string $client_secret, string $tenant_id)
+ {
+ // See https://github.com/Garethp/php-ews/blob/master/examples/basic/authenticatingWithOAuth.php
+ // See https://github.com/Garethp/php-ews/issues/236#issuecomment-1292521527
+ // To register OAuth2 app goto https://entra.microsoft.com > Applications > App registrations
+
+ \Log::debug("[EWS] Fetching OAuth2 token from $server...");
+
+ $scope = 'https://outlook.office365.com/.default';
+ $token_uri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/v2.0/token";
+ // $authUri = "https://login.microsoftonline.com/{$tenant_id}/oauth2/authorize";
+
+ $response = Http::asForm()
+ ->timeout(5)
+ ->post($token_uri, [
+ 'client_id' => $client_id,
+ 'client_secret' => $client_secret,
+ 'scope' => $scope,
+ 'grant_type' => 'client_credentials',
+ ])
+ ->throwUnlessStatus(200);
+
+ $token = $response->json('access_token');
+
+ // Note: Office365 default token expiration time is ~1h,
+ $expires_in = $response->json('expires_in');
+ $expires_at = now()->addSeconds($expires_in)->toDateTimeString();
+
+ return $this->authenticateWithToken($server, $user, $token, $expires_at);
+ }
+
+ /**
+ * Get folders hierarchy
+ */
+ public function getFolders($types = []): array
+ {
+ // Get full folders hierarchy
+ $options = [
+ 'Traversal' => 'Deep',
+ ];
+
+ $folders = $this->api->getChildrenFolders('root', $options);
+
+ $result = [];
+
+ foreach ($folders as $folder) {
+ $class = $folder->getFolderClass();
+ $type = $this->type_map[$class] ?? null;
+
+ // Skip folder types we do not support (need)
+ if (empty($type) || (!empty($types) && !in_array($type, $types))) {
+ continue;
+ }
+
+ // Note: Folder names are localized
+ $name = $fullname = $folder->getDisplayName();
+ $id = $folder->getFolderId()->getId();
+ $parentId = $folder->getParentFolderId()->getId();
+
+ // Create folder name with full path
+ if ($parentId && !empty($result[$parentId])) {
+ $fullname = $result[$parentId]->fullname . '/' . $name;
+ }
+
+ // Top-level folder, check if it's a special folder we should ignore
+ // FIXME: Is there a better way to distinguish user folders from system ones?
+ if (
+ in_array($fullname, $this->folder_exceptions)
+ || strpos($fullname, 'OwaFV15.1All') === 0
+ ) {
+ continue;
+ }
+
+ $result[$id] = Folder::fromArray([
+ 'id' => $folder->getFolderId()->toArray(true),
+ 'total' => $folder->getTotalCount(),
+ 'class' => $class,
+ 'type' => $this->type_map[$class] ?? null,
+ 'name' => $name,
+ 'fullname' => $fullname,
+ ]);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fetch a list of folder items
+ */
+ public function fetchItemList(Folder $folder, $callback): void
+ {
+ // Job processing - initialize environment
+ $this->initEnv($this->engine->queue);
+
+ // The folder is empty, we can stop here
+ if (empty($folder->total)) {
+ return;
+ }
+
+ $request = [
+ // Exchange's maximum is 1000
+ 'IndexedPageItemView' => ['MaxEntriesReturned' => 100, 'Offset' => 0, 'BasePoint' => 'Beginning'],
+ 'ParentFolderIds' => $folder->id,
+ 'Traversal' => 'Shallow',
+ 'ItemShape' => [
+ 'BaseShape' => 'IdOnly',
+ 'AdditionalProperties' => [
+ 'FieldURI' => ['FieldURI' => 'item:ItemClass'],
+ ],
+ ],
+ ];
+
+ $request = Type::buildFromArray($request);
+
+ // Note: It is not possible to get mimeContent with FindItem request
+ // That's why we first get the list of object identifiers and
+ // then call GetItem on each separately.
+
+ // TODO: It might be feasible to get all properties for object types
+ // for which we don't use MimeContent, for better performance.
+
+ // Request first page
+ $response = $this->api->getClient()->FindItem($request);
+
+ // @phpstan-ignore-next-line
+ foreach ($response as $item) {
+ if ($item = $this->toItem($item, $folder)) {
+ $callback($item);
+ }
+ }
+
+ // Request other pages until we got all
+ while (!$response->isIncludesLastItemInRange()) {
+ // @phpstan-ignore-next-line
+ $response = $this->api->getNextPage($response);
+
+ foreach ($response as $item) {
+ if ($item = $this->toItem($item, $folder)) {
+ $callback($item);
+ }
+ }
+ }
+ }
+
+ /**
+ * Fetching an item
+ */
+ public function fetchItem(Item $item): string
+ {
+ // Job processing - initialize environment
+ $this->initEnv($this->engine->queue);
+
+ if ($driver = EWS\Item::factory($this, $item)) {
+ return $driver->fetchItem($item);
+ }
+
+ throw new \Exception("Failed to fetch an item from EWS");
+ }
+
+ /**
+ * Get the source account
+ */
+ public function getSourceAccount(): Account
+ {
+ return $this->engine->source;
+ }
+
+ /**
+ * Get the destination account
+ */
+ public function getDestinationAccount(): Account
+ {
+ return $this->engine->destination;
+ }
+
+ /**
+ * Synchronize specified object
+ */
+ protected function toItem(Type $item, Folder $folder): ?Item
+ {
+ $item = Item::fromArray([
+ 'id' => $item->getItemId()->toArray(),
+ 'class' => $item->getItemClass(),
+ 'folder' => $folder,
+ ]);
+
+ // TODO: We don't need to instantiate Item at this point, instead
+ // implement EWS\Item::validateClass() method
+ if ($driver = EWS\Item::factory($this, $item)) {
+ return $item;
+ }
+
+ return null;
+ }
+
+ /**
+ * Set common API options
+ */
+ protected function apiOptions(array $options): array
+ {
+ if (empty($options['version'])) {
+ $options['version'] = API\ExchangeWebServices::VERSION_2013;
+ }
+
+ // If you want to inject your own GuzzleClient for the requests
+ // $options['httpClient]' = $client;
+
+ // In debug mode record all responses
+ /*
+ if (\config('app.debug')) {
+ $options['httpPlayback'] = [
+ 'mode' => 'record',
+ 'recordLocation' => \storage_path('ews'),
+ ];
+ }
+ */
+
+ return $options;
+ }
+
+ /**
+ * Initialize environment for job execution
+ *
+ * @param Queue $queue Queue
+ */
+ protected function initEnv(Queue $queue): void
+ {
+ $ews = $queue->data['options']['ews'];
+
+ if (!empty($ews['token'])) {
+ // TODO: Refresh the token if needed
+ $this->api = API::withCallbackToken(
+ $ews['server'],
+ $ews['token'],
+ $this->apiOptions($ews['options'])
+ );
+ } else {
+ $this->api = API::withUsernameAndPassword(
+ $ews['server'],
+ $this->account->username,
+ $this->account->password,
+ $this->apiOptions($ews['options'])
+ );
+ }
+ }
+}
diff --git a/src/app/DataMigrator/EWS/Appointment.php b/src/app/DataMigrator/EWS/Appointment.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Appointment.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API;
+use garethp\ews\API\Type;
+
+/**
+ * Appointment (calendar event) object handler
+ */
+class Appointment extends Item
+{
+ public const FOLDER_TYPE = 'IPF.Appointment';
+ public const TYPE = 'IPM.Appointment';
+ public const FILE_EXT = 'ics';
+
+
+ /**
+ * Get GetItem request parameters
+ */
+ protected function getItemRequest(): array
+ {
+ $request = parent::getItemRequest();
+
+ // Request IncludeMimeContent as it's not included by default
+ $request['ItemShape']['IncludeMimeContent'] = true;
+
+ // Get UID property, it's not included in the Default set
+ // FIXME: How to add the property to the set, not replace the whole set
+ $request['ItemShape']['AdditionalProperties']['FieldURI'] = ['FieldURI' => 'calendar:UID'];
+
+ return $request;
+ }
+
+ /**
+ * Process event object
+ */
+ protected function processItem(Type $item)
+ {
+ // Decode MIME content
+ $ical = base64_decode((string) $item->getMimeContent());
+
+ $itemId = implode("\r\n ", str_split($this->itemId, 75 - strlen('X-MS-ID:')));
+
+ $ical = str_replace("\r\nBEGIN:VEVENT\r\n", "\r\nBEGIN:VEVENT\r\nX-MS-ID:{$itemId}\r\n", $ical);
+
+ // TODO: replace source email with destination email address in ORGANIZER/ATTENDEE
+
+ // Inject attachment bodies into the iCalendar content
+ // Calendar event attachments are exported as:
+ // ATTACH:CID:81490FBA13A3DC2BF071B894C96B44BA51BEAAED@eurprd05.prod.outlook.com
+ if ($item->getHasAttachments()) {
+ // FIXME: I've tried hard and no matter what ContentId property is always empty
+ // This means we can't match the CID from iCalendar with the attachment.
+ // That's why we'll just remove all ATTACH:CID:... occurrences
+ // and inject attachments to the main event
+ $ical = preg_replace('/\r\nATTACH:CID:[^\r]+\r\n(\r\n [^\r\n]*)?/', '', $ical);
+
+ foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) {
+ $_attachment = $this->getAttachment($attachment);
+
+ $ctype = $_attachment->getContentType();
+ $body = $_attachment->getContent();
+
+ // It looks like Exchange may have an issue with plain text files.
+ // We'll skip empty files
+ if (!strlen($body)) {
+ continue;
+ }
+
+ // FIXME: This is imo inconsistence on php-ews side that MimeContent
+ // is base64 encoded, but Content isn't
+ // TODO: We should not do it in memory to not exceed the memory limit
+ $body = base64_encode($body);
+ $body = rtrim(chunk_split($body, 74, "\r\n "), ' ');
+
+ // Inject the attachment at the end of the first VEVENT block
+ // TODO: We should not do it in memory to not exceed the memory limit
+ $append = "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}";
+ $pos = strpos($ical, "\r\nEND:VEVENT");
+ $ical = substr_replace($ical, $append, $pos + 2, 0);
+ }
+ }
+
+ return $ical;
+ }
+
+ /**
+ * Get Item UID (Generate a new one if needed)
+ */
+ protected function getUID(Type $item): string
+ {
+ if ($this->uid === null) {
+ // Only appointments have UID property
+ $this->uid = $item->getUID();
+ }
+
+ return $this->uid;
+ }
+}
diff --git a/src/app/DataMigrator/EWS/Contact.php b/src/app/DataMigrator/EWS/Contact.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Contact.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * Contact object handler
+ */
+class Contact extends Item
+{
+ public const FOLDER_TYPE = 'IPF.Contact';
+ public const TYPE = 'IPM.Contact';
+ public const FILE_EXT = 'vcf';
+
+ /**
+ * Get GetItem request parameters
+ */
+ protected function getItemRequest(): array
+ {
+ $request = parent::getItemRequest();
+
+ // Request IncludeMimeContent as it's not included by default
+ $request['ItemShape']['IncludeMimeContent'] = true;
+
+ return $request;
+ }
+
+ /**
+ * Process contact object
+ */
+ protected function processItem(Type $item)
+ {
+ // Decode MIME content
+ $vcard = base64_decode((string) $item->getMimeContent());
+
+ // Remove empty properties that EWS is exporting
+ $vcard = preg_replace('|\n[^:]+:;*\r|', '', $vcard);
+
+ // Inject UID (and Exchange item ID) to the vCard
+ $uid = $this->getUID($item);
+ $itemId = implode("\r\n ", str_split($this->itemId, 75 - strlen('X-MS-ID:')));
+
+ $vcard = str_replace("BEGIN:VCARD", "BEGIN:VCARD\r\nUID:{$uid}\r\nX-MS-ID:{$itemId}", $vcard);
+
+ // Note: Looks like PHOTO property is exported properly, so we
+ // don't have to handle attachments as we do for calendar items
+
+ return $vcard;
+ }
+}
diff --git a/src/app/DataMigrator/EWS/DistList.php b/src/app/DataMigrator/EWS/DistList.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/DistList.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * Distribution List object handler
+ */
+class DistList extends Item
+{
+ public const FOLDER_TYPE = 'IPF.Contact';
+ public const TYPE = 'IPM.DistList';
+ public const FILE_EXT = 'vcf';
+
+ /**
+ * Convert distribution list object to vCard
+ */
+ protected function processItem(Type $item)
+ {
+ // Groups (Distribution Lists) are not exported in vCard format, they use eml
+
+ $data = [
+ 'UID' => [$this->getUID($item)],
+ 'KIND' => ['group'],
+ 'FN' => [$item->getDisplayName()],
+ 'REV' => [$item->getLastModifiedTime(), ['VALUE' => 'DATE-TIME']],
+ 'X-MS-ID' => [$this->itemId],
+ ];
+
+ $vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:Kolab EWS Data Migrator\r\n";
+
+ foreach ($data as $key => $prop) {
+ $vcard .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []);
+ }
+
+ // Process list members
+ // Note: The fact that getMembers() returns stdClass is probably a bug in php-ews
+ foreach ($item->getMembers()->Member as $member) {
+ $mailbox = $member->getMailbox();
+ $mailto = $mailbox->getEmailAddress();
+ $name = $mailbox->getName();
+
+ // FIXME: Investigate if mailto: members are handled properly by Kolab
+ // or we need to use MEMBER:urn:uuid:9bd97510-9dbb-4810-a144-6180962df5e0 syntax
+ // But do not forget lists can have members that are not contacts
+
+ if ($mailto) {
+ if ($name && $name != $mailto) {
+ $mailto = urlencode(sprintf('"%s" <%s>', addcslashes($name, '"'), $mailto));
+ }
+
+ $vcard .= $this->formatProp('MEMBER', "mailto:{$mailto}");
+ }
+ }
+
+ $vcard .= "END:VCARD\r\n";
+
+ return $vcard;
+ }
+}
diff --git a/src/app/DataMigrator/EWS/Item.php b/src/app/DataMigrator/EWS/Item.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Item.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use App\DataMigrator\EWS;
+use App\DataMigrator\Interface\Folder as FolderInterface;
+use App\DataMigrator\Interface\Item as ItemInterface;
+use garethp\ews\API;
+use garethp\ews\API\Type;
+
+/**
+ * Abstraction for object handlers
+ */
+abstract class Item
+{
+ /** @var EWS Data migrator object */
+ protected $driver;
+
+ /** @var FolderInterface Current folder */
+ protected $folder;
+
+ /** @var string Current item ID */
+ protected $itemId;
+
+ /** @var string Current item UID */
+ protected $uid;
+
+
+ /**
+ * Object constructor
+ */
+ public function __construct(EWS $driver, FolderInterface $folder)
+ {
+ $this->driver = $driver;
+ $this->folder = $folder;
+ }
+
+ /**
+ * Factory method.
+ * Returns object suitable to handle specified item type.
+ */
+ public static function factory(EWS $driver, ItemInterface $item)
+ {
+ $item_class = str_replace('IPM.', '', $item->class);
+ $item_class = "\App\DataMigrator\EWS\\{$item_class}";
+
+ if (class_exists($item_class)) {
+ return new $item_class($driver, $item->folder);
+ }
+ }
+
+ /**
+ * Fetch the specified object and put into a file
+ */
+ public function fetchItem(ItemInterface $item)
+ {
+ $itemId = $item->id;
+
+ // Fetch the item
+ $item = $this->driver->api->getItem($itemId, $this->getItemRequest());
+
+ $this->itemId = implode('!', $itemId);
+
+ $uid = $this->getUID($item);
+
+ \Log::debug("[EWS] Saving item {$uid}...");
+
+ // Apply type-specific format converters
+ $content = $this->processItem($item);
+
+ if (!is_string($content)) {
+ return;
+ }
+
+ $uid = preg_replace('/[^a-zA-Z0-9_:@-]/', '', $uid);
+
+ $location = $this->folder->location;
+
+ if (!file_exists($location)) {
+ mkdir($location, 0740, true);
+ }
+
+ $location .= '/' . $uid . '.' . $this->fileExtension();
+
+ file_put_contents($location, $content);
+
+ return $location;
+ }
+
+ /**
+ * Item conversion code
+ */
+ abstract protected function processItem(Type $item);
+
+ /**
+ * Get GetItem request parameters
+ */
+ protected function getItemRequest(): array
+ {
+ $request = [
+ 'ItemShape' => [
+ // Reqest default set of properties
+ 'BaseShape' => 'Default',
+ // Additional properties, e.g. LastModifiedTime
+ // FIXME: How to add multiple properties here?
+ 'AdditionalProperties' => [
+ 'FieldURI' => ['FieldURI' => 'item:LastModifiedTime'],
+ ]
+ ]
+ ];
+
+ return $request;
+ }
+
+ /**
+ * Fetch attachment object from Exchange
+ */
+ protected function getAttachment(Type\FileAttachmentType $attachment)
+ {
+ $request = [
+ 'AttachmentIds' => [
+ $attachment->getAttachmentId()->toXmlObject()
+ ],
+ 'AttachmentShape' => [
+ 'IncludeMimeContent' => true,
+ ]
+ ];
+
+ return $this->driver->api->getClient()->GetAttachment($request);
+ }
+
+ /**
+ * Get Item UID (Generate a new one if needed)
+ */
+ protected function getUID(Type $item): string
+ {
+ if ($this->uid === null) {
+ // We should generate an UID for objects that do not have it
+ // and inject it into the output file
+ // FIXME: Should we use e.g. md5($itemId->getId()) instead?
+ $this->uid = \App\Utils::uuidStr();
+ }
+
+ return $this->uid;
+ }
+
+ /**
+ * Filename extension for cached file in-processing
+ */
+ protected function fileExtension(): string
+ {
+ return constant(static::class . '::FILE_EXT') ?: 'txt';
+ }
+
+ /**
+ * VCard/iCal property formatting
+ */
+ protected function formatProp($name, $value, array $params = []): string
+ {
+ $cal = new \Sabre\VObject\Component\VCalendar();
+ $prop = new \Sabre\VObject\Property\Text($cal, $name, $value, $params);
+
+ $value = $prop->serialize();
+
+ // Revert escaping for some props
+ if ($name == 'RRULE') {
+ $value = str_replace("\\", '', $value);
+ }
+
+ return $value;
+ }
+}
diff --git a/src/app/DataMigrator/EWS/Note.php b/src/app/DataMigrator/EWS/Note.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Note.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * E-Mail object handler
+ */
+class Note extends Item
+{
+ public const FOLDER_TYPE = 'IPF.Note';
+ public const TYPE = 'IPM.Note';
+ public const FILE_EXT = 'eml';
+
+ /**
+ * Get GetItem request parameters
+ */
+ protected function getItemRequest(): array
+ {
+ $request = parent::getItemRequest();
+
+ // Request IncludeMimeContent as it's not included by default
+ $request['ItemShape']['IncludeMimeContent'] = true;
+
+ // For email we need all properties
+ $request['ItemShape']['BaseShape'] = 'AllProperties';
+
+ return $request;
+ }
+
+ /**
+ * Process contact object
+ */
+ protected function processItem(Type $item)
+ {
+ $email = base64_decode((string) $item->getMimeContent());
+
+ return $email;
+ }
+}
diff --git a/src/app/DataMigrator/EWS/Task.php b/src/app/DataMigrator/EWS/Task.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/EWS/Task.php
@@ -0,0 +1,305 @@
+<?php
+
+namespace App\DataMigrator\EWS;
+
+use garethp\ews\API\Type;
+
+/**
+ * Task objects handler
+ */
+class Task extends Item
+{
+ public const FOLDER_TYPE = 'IPF.Task';
+ public const TYPE = 'IPM.Task';
+ public const FILE_EXT = 'ics';
+
+ /**
+ * Get GetItem request parameters
+ */
+ protected function getItemRequest(): array
+ {
+ $request = parent::getItemRequest();
+
+ // For tasks we need all properties
+ $request['ItemShape']['BaseShape'] = 'AllProperties';
+
+ return $request;
+ }
+
+ /**
+ * Process task object
+ */
+ protected function processItem(Type $item)
+ {
+ // Tasks are exported as Email messages in useless format
+ // (does not contain all relevant properties)
+ // We'll build the iCalendar using properties directly
+ // TODO: This probably should be done with sabre-vobject
+
+ // FIXME: Looks like tasks do not have timezone specified in EWS
+ // and dates are in UTC, shall we remove the 'Z' from dates to make them floating?
+
+ $data = [
+ 'UID' => [$this->getUID($item)],
+ 'DTSTAMP' => [$this->formatDate($item->getLastModifiedTime()), ['VALUE' => 'DATE-TIME']],
+ 'CREATED' => [$this->formatDate($item->getDateTimeCreated()), ['VALUE' => 'DATE-TIME']],
+ 'SEQUENCE' => [intval($item->getChangeCount())],
+ 'SUMMARY' => [$item->getSubject()],
+ 'DESCRIPTION' => [(string) $item->getBody()],
+ 'PERCENT-COMPLETE' => [intval($item->getPercentComplete())],
+ 'STATUS' => [strtoupper($item->getStatus())],
+ 'X-MS-ID' => [$this->itemId],
+ ];
+
+ if ($dueDate = $item->getDueDate()) {
+ $data['DUE'] = [$this->formatDate($dueDate), ['VALUE' => 'DATE-TIME']];
+ }
+
+ if ($startDate = $item->getStartDate()) {
+ $data['DTSTART'] = [$this->formatDate($startDate), ['VALUE' => 'DATE-TIME']];
+ }
+
+ if (($categories = $item->getCategories()) && $categories->String) {
+ $data['CATEGORIES'] = [$categories->String];
+ }
+
+ if ($sensitivity = $item->getSensitivity()) {
+ $sensitivity_map = [
+ 'CONFIDENTIAL' => 'CONFIDENTIAL',
+ 'NORMAL' => 'PUBLIC',
+ 'PERSONAL' => 'PUBLIC',
+ 'PRIVATE' => 'PRIVATE',
+ ];
+
+ $data['CLASS'] = [$sensitivity_map[strtoupper($sensitivity)] ?? 'PUBLIC'];
+ }
+
+ if ($importance = $item->getImportance()) {
+ $importance_map = [
+ 'HIGH' => '9',
+ 'NORMAL' => '5',
+ 'LOW' => '1',
+ ];
+
+ $data['PRIORITY'] = [$importance_map[strtoupper($importance)] ?? '0'];
+ }
+
+ $this->setTaskOrganizer($data, $item);
+ $this->setTaskRecurrence($data, $item);
+
+ $ical = "BEGIN:VCALENDAR\r\nMETHOD:PUBLISH\r\nVERSION:2.0\r\nPRODID:Kolab EWS Data Migrator\r\nBEGIN:VTODO\r\n";
+
+ foreach ($data as $key => $prop) {
+ $ical .= $this->formatProp($key, $prop[0], isset($prop[1]) ? $prop[1] : []);
+ }
+
+ // Attachments
+ if ($item->getHasAttachments()) {
+ foreach ((array) $item->getAttachments()->getFileAttachment() as $attachment) {
+ $_attachment = $this->getAttachment($attachment);
+
+ $ctype = $_attachment->getContentType();
+ $body = $_attachment->getContent();
+
+ // It looks like Exchange may have an issue with plain text files.
+ // We'll skip empty files
+ if (!strlen($body)) {
+ continue;
+ }
+
+ // FIXME: This is imo inconsistence on php-ews side that MimeContent
+ // is base64 encoded, but Content isn't
+ // TODO: We should not do it in memory to not exceed the memory limit
+ $body = base64_encode($body);
+ $body = rtrim(chunk_split($body, 74, "\r\n "), ' ');
+
+ // Inject the attachment at the end of the VTODO block
+ // TODO: We should not do it in memory to not exceed the memory limit
+ $ical .= "ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE={$ctype}:\r\n {$body}";
+ }
+ }
+
+ $ical .= $this->getVAlarm($item);
+ $ical .= "END:VTODO\r\n";
+ $ical .= "END:VCALENDAR\r\n";
+
+ return $ical;
+ }
+
+ /**
+ * Set task organizer/attendee
+ */
+ protected function setTaskOrganizer(array &$data, Type $task)
+ {
+ // FIXME: Looks like the owner might be an email address or just a full user name
+ $owner = $task->getOwner();
+ $source = $this->driver->getSourceAccount();
+ $destination = $this->driver->getDestinationAccount();
+
+ if (strpos($owner, '@') && $owner != $source->email) {
+ // Task owned by another person
+ $data['ORGANIZER'] = ["mailto:{$owner}"];
+
+ // FIXME: Because attendees are not specified in EWS, assume the user is an attendee
+ if ($destination->email) {
+ $params = ['ROLE' => 'REQ-PARTICIPANT', 'CUTYPE' => 'INDIVIDUAL'];
+ $data['ATTENDEE'] = ["mailto:{$destination->email}", $params];
+ }
+
+ return;
+ }
+
+ // Otherwise it must be owned by the user
+ if ($destination->email) {
+ $data['ORGANIZER'] = ["mailto:{$destination->email}"];
+ }
+ }
+
+ /**
+ * Set task recurrence rule
+ */
+ protected function setTaskRecurrence(array &$data, Type $task)
+ {
+ if (empty($task->getIsRecurring()) || empty($task->getRecurrence())) {
+ return;
+ }
+
+ $r = $task->getRecurrence();
+ $rrule = [];
+
+ if ($recurrence = $r->getDailyRecurrence()) {
+ $rrule['FREQ'] = 'DAILY';
+ $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+ } elseif ($recurrence = $r->getWeeklyRecurrence()) {
+ $rrule['FREQ'] = 'WEEKLY';
+ $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+ $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek());
+ $rrule['WKST'] = $this->mapDays($recurrence->getFirstDayOfWeek());
+ } elseif ($recurrence = $r->getAbsoluteMonthlyRecurrence()) {
+ $rrule['FREQ'] = 'MONTHLY';
+ $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+ $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth();
+ } elseif ($recurrence = $r->getRelativeMonthlyRecurrence()) {
+ $rrule['FREQ'] = 'MONTHLY';
+ $rrule['INTERVAL'] = $recurrence->getInterval() ?: 1;
+ $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex());
+ } elseif ($recurrence = $r->getAbsoluteYearlyRecurrence()) {
+ $rrule['FREQ'] = 'YEARLY';
+ $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth());
+ $rrule['BYMONTHDAY'] = $recurrence->getDayOfMonth();
+ } elseif ($recurrence = $r->getRelativeYearlyRecurrence()) {
+ $rrule['FREQ'] = 'YEARLY';
+ $rrule['BYMONTH'] = $this->mapMonths($recurrence->getMonth());
+ $rrule['BYDAY'] = $this->mapDays($recurrence->getDaysOfWeek(), $recurrence->getDayOfWeekIndex());
+ } else {
+ // There might be *Regeneration rules that we don't support
+ \Log::debug("[EWS] Unsupported Recurrence property value. Ignored.");
+ }
+
+ if (!empty($rrule)) {
+ if ($recurrence = $r->getNumberedRecurrence()) {
+ $rrule['COUNT'] = $recurrence->getNumberOfOccurrences();
+ } elseif ($recurrence = $r->getEndDateRecurrence()) {
+ $rrule['UNTIL'] = $this->formatDate($recurrence->getEndDate());
+ }
+
+ $rrule = array_filter($rrule);
+ $rrule = trim(array_reduce(
+ array_keys($rrule),
+ function ($carry, $key) use ($rrule) {
+ return $carry . ';' . $key . '=' . $rrule[$key];
+ }
+ ), ';');
+
+ $data['RRULE'] = [$rrule];
+ }
+ }
+
+ /**
+ * Get VALARM block for the task Reminder
+ */
+ protected function getVAlarm(Type $task): string
+ {
+ // FIXME: To me it looks like ReminderMinutesBeforeStart property is not used
+
+ $date = $this->formatDate($task->getReminderDueBy());
+
+ if (empty($task->getReminderIsSet()) || empty($date)) {
+ return '';
+ }
+
+ return "BEGIN:VALARM\r\nACTION:DISPLAY\r\n"
+ . "TRIGGER;VALUE=DATE-TIME:{$date}\r\n"
+ . "END:VALARM\r\n";
+ }
+
+ /**
+ * Convert EWS representation of recurrence days to iCal
+ */
+ protected function mapDays(string $days, string $index = ''): string
+ {
+ if (preg_match('/(Day|Weekday|WeekendDay)/', $days)) {
+ // not supported
+ return '';
+ }
+
+ $days_map = [
+ 'Sunday' => 'SU',
+ 'Monday' => 'MO',
+ 'Tuesday' => 'TU',
+ 'Wednesday' => 'WE',
+ 'Thursday' => 'TH',
+ 'Friday' => 'FR',
+ 'Saturday' => 'SA',
+ ];
+
+ $index_map = [
+ 'First' => 1,
+ 'Second' => 2,
+ 'Third' => 3,
+ 'Fourth' => 4,
+ 'Last' => -1,
+ ];
+
+ $days = explode(' ', $days);
+ $days = array_map(
+ function ($day) use ($days_map, $index_map, $index) {
+ return ($index ? $index_map[$index] : '') . $days_map[$day];
+ },
+ $days
+ );
+
+ return implode(',', $days);
+ }
+
+ /**
+ * Convert EWS representation of recurrence month to iCal
+ */
+ protected function mapMonths(string $months): string
+ {
+ $months_map = ['January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'];
+
+ $months = explode(' ', $months);
+ $months = array_map(
+ function ($month) use ($months_map) {
+ return array_search($month, $months_map) + 1;
+ },
+ $months
+ );
+
+ return implode(',', $months);
+ }
+
+ /**
+ * Format EWS date-time into a iCalendar date-time
+ */
+ protected function formatDate($datetime)
+ {
+ if (empty($datetime)) {
+ return null;
+ }
+
+ return str_replace(['Z', '-', ':'], '', $datetime);
+ }
+}
diff --git a/src/app/DataMigrator/Engine.php b/src/app/DataMigrator/Engine.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Engine.php
@@ -0,0 +1,273 @@
+<?php
+
+namespace App\DataMigrator;
+
+use App\DataMigrator\Interface\ExporterInterface;
+use App\DataMigrator\Interface\Folder;
+use App\DataMigrator\Interface\ImporterInterface;
+use App\DataMigrator\Interface\Item;
+use Illuminate\Support\Str;
+
+/**
+ * Data migration engine
+ */
+class Engine
+{
+ public const TYPE_CONTACT = 'contact';
+ public const TYPE_EVENT = 'event';
+ public const TYPE_GROUP = 'group';
+ public const TYPE_MAIL = 'mail';
+ public const TYPE_NOTE = 'note';
+ public const TYPE_TASK = 'task';
+
+ /** @var Account Source account */
+ public $source;
+
+ /** @var Account Destination account */
+ public $destination;
+
+ /** @var ?Queue Data migration queue (session) */
+ public $queue;
+
+ /** @var ExporterInterface Data exporter */
+ protected $exporter;
+
+ /** @var ImporterInterface Data importer */
+ protected $importer;
+
+ /** @var array Data migration options */
+ protected $options = [];
+
+
+ /**
+ * Execute migration for the specified user
+ */
+ public function migrate(Account $source, Account $destination, array $options = [])
+ {
+ $this->source = $source;
+ $this->destination = $destination;
+ $this->options = $options;
+
+ // Create a unique identifier for the migration request
+ $queue_id = md5(strval($source) . strval($destination) . $options['type']);
+
+ // If queue exists, we'll display the progress only
+ if ($queue = Queue::find($queue_id)) {
+ // If queue contains no jobs, assume invalid
+ // TODO: An better API to manage (reset) queues
+ if (!$queue->jobs_started || !empty($options['force'])) {
+ $queue->delete();
+ } else {
+ while (true) {
+ $this->debug(sprintf("Progress [%d of %d]\n", $queue->jobs_finished, $queue->jobs_started));
+
+ if ($queue->jobs_started == $queue->jobs_finished) {
+ break;
+ }
+
+ sleep(1);
+ $queue->refresh();
+ }
+
+ return;
+ }
+ }
+
+ // Initialize the source
+ $this->exporter = $this->initDriver($source, ExporterInterface::class);
+ $this->exporter->authenticate();
+
+ // Initialize the destination
+ $this->importer = $this->initDriver($destination, ImporterInterface::class);
+ $this->importer->authenticate();
+
+ // $this->debug("Source/destination user credentials verified.");
+ $this->debug("Fetching folders hierarchy...");
+
+ // Create a queue
+ $this->createQueue($queue_id);
+
+ // We'll store output in storage/<username> tree
+ $location = storage_path('export/') . $source->email;
+
+ if (!file_exists($location)) {
+ mkdir($location, 0740, true);
+ }
+
+ $types = preg_split('/\s*,\s*/', strtolower($this->options['type'] ?? ''));
+
+ $folders = $this->exporter->getFolders($types);
+ $count = 0;
+
+ foreach ($folders as $folder) {
+ $this->debug("Processing folder {$folder->fullname}...");
+
+ $folder->queueId = $queue_id;
+ $folder->location = $location;
+
+ // Dispatch the job (for async execution)
+ Jobs\FolderJob::dispatch($folder);
+ $count++;
+ }
+
+ $this->queue->bumpJobsStarted($count);
+
+ $this->debug(sprintf('Done. %d %s created in queue: %s.', $count, Str::plural('job', $count), $queue_id));
+ }
+
+ /**
+ * Processing of a folder synchronization
+ */
+ public function processFolder(Folder $folder): void
+ {
+ // Job processing - initialize environment
+ $this->envFromQueue($folder->queueId);
+
+ // Create the folder on the destination server
+ $this->importer->createFolder($folder);
+
+ $count = 0;
+
+ // Fetch items from the source
+ $this->exporter->fetchItemList(
+ $folder,
+ function (Item $item) use (&$count) {
+ // Dispatch the job (for async execution)
+ Jobs\ItemJob::dispatch($item);
+ $count++;
+ }
+ );
+
+ if ($count) {
+ $this->queue->bumpJobsStarted($count);
+ }
+
+ $this->queue->bumpJobsFinished();
+ }
+
+ /**
+ * Processing of item synchronization
+ */
+ public function processItem(Item $item): void
+ {
+ // Job processing - initialize environment
+ $this->envFromQueue($item->folder->queueId);
+
+ if ($filename = $this->exporter->fetchItem($item)) {
+ $this->importer->createItemFromFile($filename, $item->folder);
+ // TODO: remove the file
+ }
+
+ $this->queue->bumpJobsFinished();
+ }
+
+ /**
+ * Print progress/debug information
+ */
+ public function debug($line)
+ {
+ if (!empty($this->options['stdout'])) {
+ $output = new \Symfony\Component\Console\Output\ConsoleOutput();
+ $output->writeln("$line");
+ } else {
+ \Log::debug("[DataMigrator] $line");
+ }
+ }
+
+ /**
+ * Set migration queue option. Use this if you need to pass
+ * some data between queue processes.
+ */
+ public function setOption(string $name, $value): void
+ {
+ $this->options[$name] = $value;
+
+ if ($this->queue) {
+ $this->queue->data = $this->queueData();
+ $this->queue->save();
+ }
+ }
+
+ /**
+ * Create a queue for the request
+ *
+ * @param string $queue_id Unique queue identifier
+ */
+ protected function createQueue(string $queue_id): void
+ {
+ $this->queue = new Queue();
+ $this->queue->id = $queue_id;
+ $this->queue->data = $this->queueData();
+ $this->queue->save();
+ }
+
+ /**
+ * Prepare queue data
+ */
+ protected function queueData()
+ {
+ $options = $this->options;
+ unset($options['stdout']); // jobs aren't in stdout anymore
+
+ // TODO: data should be encrypted
+ return [
+ 'source' => (string) $this->source,
+ 'destination' => (string) $this->destination,
+ 'options' => $options,
+ ];
+ }
+
+ /**
+ * Initialize environment for job execution
+ *
+ * @param string $queueId Queue identifier
+ */
+ protected function envFromQueue(string $queueId): void
+ {
+ $this->queue = Queue::findOrFail($queueId);
+
+ $this->source = new Account($this->queue->data['source']);
+ $this->destination = new Account($this->queue->data['destination']);
+ $this->options = $this->queue->data['options'];
+
+ $this->importer = $this->initDriver($this->destination, ImporterInterface::class);
+ $this->exporter = $this->initDriver($this->source, ExporterInterface::class);
+ }
+
+ /**
+ * Initialize (and select) migration driver
+ */
+ protected function initDriver(Account $account, string $interface)
+ {
+ switch ($account->scheme) {
+ case 'ews':
+ $driver = new EWS($account, $this);
+ break;
+
+ case 'dav':
+ case 'davs':
+ $driver = new DAV($account, $this);
+ break;
+ /*
+ case 'imap':
+ case 'imaps':
+ $driver = new IMAP($account, $this);
+ break;
+ */
+
+ default:
+ throw new \Exception("Failed to init driver for '{$account->scheme}'");
+ }
+
+ // Make sure driver is used in the direction it supports
+ if (!is_a($driver, $interface)) {
+ throw new \Exception(sprintf(
+ "'%s' driver does not implement %s",
+ class_basename($driver),
+ class_basename($interface)
+ ));
+ }
+
+ return $driver;
+ }
+}
diff --git a/src/app/DataMigrator/Interface/ExporterInterface.php b/src/app/DataMigrator/Interface/ExporterInterface.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/ExporterInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Engine;
+
+interface ExporterInterface
+{
+ /**
+ * Object constructor
+ */
+ public function __construct(Account $account, Engine $engine);
+
+ /**
+ * Check user credentials.
+ *
+ * @throws \Exception
+ */
+ public function authenticate();
+
+ /**
+ * Get folders hierarchy
+ */
+ public function getFolders($types = []): array;
+
+ /**
+ * Fetch a list of folder items
+ */
+ public function fetchItemList(Folder $folder, $callback): void;
+
+ /**
+ * Fetching an item
+ */
+ public function fetchItem(Item $item): string;
+}
diff --git a/src/app/DataMigrator/Interface/Folder.php b/src/app/DataMigrator/Interface/Folder.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/Folder.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+use App\DataMigrator\Queue;
+
+/**
+ * Data object representing a folder
+ */
+class Folder
+{
+ /** @var mixed Folder identifier */
+ public $id;
+
+ /** @var int Number of items in the folder */
+ public $total;
+
+ /** @var string Folder class */
+ public $class;
+
+ /** @var string Folder Kolab object type */
+ public $type;
+
+ /** @var string Folder name */
+ public $name;
+
+ /** @var string Folder name with path */
+ public $fullname;
+
+ /** @var string Storage location (for temporary data) */
+ public $location;
+
+ /** @var string Migration queue identifier */
+ public $queueId;
+
+
+ public static function fromArray(array $data = [])
+ {
+ $obj = new self();
+
+ foreach ($data as $key => $value) {
+ $obj->{$key} = $value;
+ }
+
+ return $obj;
+ }
+}
diff --git a/src/app/DataMigrator/Interface/ImporterInterface.php b/src/app/DataMigrator/Interface/ImporterInterface.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/ImporterInterface.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+use App\DataMigrator\Account;
+use App\DataMigrator\Engine;
+
+interface ImporterInterface
+{
+ /**
+ * Object constructor
+ */
+ public function __construct(Account $account, Engine $engine);
+
+ /**
+ * Check user credentials.
+ *
+ * @throws \Exception
+ */
+ public function authenticate();
+
+ /**
+ * Create an item in a folder.
+ *
+ * @param string $filename File location
+ * @param Folder $folder Folder object
+ *
+ * @throws \Exception
+ */
+ public function createItemFromFile(string $filename, Folder $folder): void;
+
+ /**
+ * Create a folder.
+ *
+ * @param Folder $folder Folder object
+ *
+ * @throws \Exception
+ */
+ public function createFolder(Folder $folder): void;
+}
diff --git a/src/app/DataMigrator/Interface/Item.php b/src/app/DataMigrator/Interface/Item.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Interface/Item.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\DataMigrator\Interface;
+
+/**
+ * Data object representing a data item
+ */
+class Item
+{
+ /** @var mixed Item identifier */
+ public $id;
+
+ /** @var Folder Folder */
+ public $folder;
+
+ /** @var string Object class */
+ public $class;
+
+
+ public static function fromArray(array $data = [])
+ {
+ $obj = new self();
+
+ foreach ($data as $key => $value) {
+ $obj->{$key} = $value;
+ }
+
+ return $obj;
+ }
+}
diff --git a/src/app/DataMigrator/Jobs/FolderJob.php b/src/app/DataMigrator/Jobs/FolderJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Jobs/FolderJob.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\DataMigrator\Jobs;
+
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\Folder;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class FolderJob implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 3;
+
+ /** @var Folder Job data */
+ protected $folder;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param Folder $folder Folder to process
+ *
+ * @return void
+ */
+ public function __construct(Folder $folder)
+ {
+ $this->folder = $folder;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $migrator = new Engine();
+ $migrator->processFolder($this->folder);
+ }
+
+ /**
+ * The job failed to process.
+ *
+ * @param \Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $exception)
+ {
+ // TODO: Count failed jobs in the queue
+ // I'm not sure how to do this after the final failure (after X tries)
+ // In other words how do we know all jobs in a queue finished (successfully or not)
+ // Probably we have to set $tries = 1
+ }
+}
diff --git a/src/app/DataMigrator/Jobs/ItemJob.php b/src/app/DataMigrator/Jobs/ItemJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Jobs/ItemJob.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace App\DataMigrator\Jobs;
+
+use App\DataMigrator\Engine;
+use App\DataMigrator\Interface\Item;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+
+class ItemJob implements ShouldQueue
+{
+ use Dispatchable;
+ use InteractsWithQueue;
+ use Queueable;
+ use SerializesModels;
+
+ /** @var int The number of times the job may be attempted. */
+ public $tries = 3;
+
+ /** @var Item Job data */
+ protected $item;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param Item $item Item to process
+ *
+ * @return void
+ */
+ public function __construct(Item $item)
+ {
+ $this->item = $item;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $migrator = new Engine();
+ $migrator->processItem($this->item);
+ }
+
+ /**
+ * The job failed to process.
+ *
+ * @param \Exception $exception
+ *
+ * @return void
+ */
+ public function failed(\Exception $exception)
+ {
+ // TODO: Count failed jobs in the queue
+ // I'm not sure how to do this after the final failure (after X tries)
+ // In other words how do we know all jobs in a queue finished (successfully or not)
+ // Probably we have to set $tries = 1
+ }
+}
diff --git a/src/app/DataMigrator/Queue.php b/src/app/DataMigrator/Queue.php
new file mode 100644
--- /dev/null
+++ b/src/app/DataMigrator/Queue.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace App\DataMigrator;
+
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * The eloquent definition of a DataMigratorQueue
+ */
+class Queue extends Model
+{
+ /** @var string Database table name */
+ protected $table = 'data_migrator_queues';
+
+ /**
+ * Indicates if the IDs are auto-incrementing.
+ *
+ * @var bool
+ */
+ public $incrementing = false;
+
+ /**
+ * The "type" of the auto-incrementing ID.
+ *
+ * @var string
+ */
+ protected $keyType = 'string';
+
+ /**
+ * Indicates if the model should be timestamped.
+ *
+ * @var bool
+ */
+ public $timestamps = true;
+
+ /**
+ * The attributes that should be cast to native types.
+ *
+ * @var array<string, string>
+ */
+ protected $casts = ['data' => 'array'];
+
+ /**
+ * The model's default values for attributes.
+ *
+ * @var array
+ */
+ protected $attributes = [
+ 'jobs_started' => 0,
+ 'jobs_finished' => 0,
+ 'data' => '', // must not be []
+ ];
+
+
+ /**
+ * Fast and race-condition free method of bumping the jobs_started value
+ */
+ public function bumpJobsStarted(int $num = null)
+ {
+ DB::update(
+ "update data_migrator_queues set jobs_started = jobs_started + ? where id = ?",
+ [$num ?: 1, $this->id]
+ );
+ }
+
+ /**
+ * Fast and race-condition free method of bumping the jobs_finished value
+ */
+ public function bumpJobsFinished(int $num = null)
+ {
+ DB::update(
+ "update data_migrator_queues set jobs_finished = jobs_finished + ? where id = ?",
+ [$num ?: 1, $this->id]
+ );
+ }
+}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -19,6 +19,7 @@
"barryvdh/laravel-dompdf": "^2.0.1",
"doctrine/dbal": "^3.6",
"dyrynda/laravel-nullable-fields": "^4.3.0",
+ "garethp/php-ews": "dev-master",
"guzzlehttp/guzzle": "^7.8.0",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "^10.15.0",
diff --git a/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php b/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2019_11_21_110000_create_data_migrator_queues_table.php
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('data_migrator_queues', function (Blueprint $table) {
+ $table->string('id', 32);
+ $table->integer('jobs_started');
+ $table->integer('jobs_finished');
+ $table->mediumText('data');
+ $table->timestamps();
+
+ $table->primary('id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::drop('data_migrator_queues');
+ }
+};
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -4,6 +4,7 @@
ignoreErrors:
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
- '#Call to an undefined method Tests\\Browser::#'
+ - '#Call to an undefined method garethp\\ews\\API\\Type::#'
level: 5
parallel:
processTimeout: 300.0
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Mar 30, 6:30 PM (5 d, 20 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18818799
Default Alt Text
D4824.1774895439.diff (75 KB)
Attached To
Mode
D4824: Data migration tool
Attached
Detach File
Event Timeline