Page MenuHomePhorge

D4824.1775309371.diff
No OneTemporary

Authored By
Unknown
Size
75 KB
Referenced Files
None
Subscribers
None

D4824.1775309371.diff

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

Mime Type
text/plain
Expires
Sat, Apr 4, 1:29 PM (21 h, 1 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18818799
Default Alt Text
D4824.1775309371.diff (75 KB)

Event Timeline