diff --git a/src/app/Console/Commands/UserMigrate.php b/src/app/Console/Commands/UserMigrate.php index af32ad7f..4d121c66 100644 --- a/src/app/Console/Commands/UserMigrate.php +++ b/src/app/Console/Commands/UserMigrate.php @@ -1,49 +1,54 @@ option('import') && empty($this->argument('dst'))) { + throw new \Exception("For import both src and dst arguments are required"); + } + $src = new DataMigrator\Account($this->argument('src')); $dst = new DataMigrator\Account($this->argument('dst')); - DataMigrator::migrate($src, $dst); + DataMigrator::migrate($src, $dst, $this->options()); } } diff --git a/src/app/DataMigrator.php b/src/app/DataMigrator.php index b53e7a6c..2e46b39a 100644 --- a/src/app/DataMigrator.php +++ b/src/app/DataMigrator.php @@ -1,25 +1,25 @@ migrate($source, $destination); + $driver->migrate($source, $destination, $options); } } diff --git a/src/app/DataMigrator/Account.php b/src/app/DataMigrator/Account.php index 06b8fc82..7eb21547 100644 --- a/src/app/DataMigrator/Account.php +++ b/src/app/DataMigrator/Account.php @@ -1,55 +1,60 @@ :" $url = parse_url($input); if ($url === false || !array_key_exists('user', $url)) { list($user, $password) = explode(':', $input, 2); $url = ['user' => $user, 'pass' => $password]; } if (isset($url['user'])) { $this->username = urldecode($url['user']); } if (isset($url['pass'])) { $this->password = urldecode($url['pass']); } if (isset($url['host'])) { $this->uri = $input; } if (strpos($this->username, '@')) { $this->email = $this->username; } } } diff --git a/src/app/DataMigrator/EWS.php b/src/app/DataMigrator/EWS.php index 8fb1c578..0db49464 100644 --- a/src/app/DataMigrator/EWS.php +++ b/src/app/DataMigrator/EWS.php @@ -1,288 +1,296 @@ DAVClient::TYPE_EVENT, EWS\Contact::FOLDER_TYPE => DAVClient::TYPE_CONTACT, EWS\Task::FOLDER_TYPE => DAVClient::TYPE_TASK, ]; /** @var string Output location */ protected $location; /** @var Account Source account */ protected $source; /** @var Account Destination account */ protected $destination; /** @var DAVClient Data importer */ protected $importer; /** * Print progress/debug information */ public function debug($line) { // TODO: When not in console mode we should // not write to stdout, but to log $output = new \Symfony\Component\Console\Output\ConsoleOutput; $output->writeln($line); } /** * Return destination account */ public function getDestination() { return $this->destination; } /** * Return source account */ public function getSource() { return $this->source; } /** * Execute migration for the specified user */ - public function migrate(Account $source, Account $destination): void + public function migrate(Account $source, Account $destination, array $options = []): void { $this->source = $source; $this->destination = $destination; - // Autodiscover and authenticate the user - $this->authenticate($source->username, $source->password); - // We'll store output in storage/ tree $this->location = storage_path('export/') . $source->email; if (!file_exists($this->location)) { mkdir($this->location, 0740, true); } + // Autodiscover and authenticate the user + $this->authenticate($source->username, $source->password); + $this->debug("Logged in. Fetching folders hierarchy..."); $folders = $this->getFolders(); - foreach ($folders as $folder) { - $this->debug("Syncing folder {$folder['fullname']}..."); + if (empty($options['import'])) { + foreach ($folders as $folder) { + $this->debug("Syncing folder {$folder['fullname']}..."); - if ($folder['total'] > 0) { - $this->syncItems($folder); + if ($folder['total'] > 0) { + $this->syncItems($folder); + } } + + $this->debug("Done."); } - $this->debug("Done."); - $this->debug("Importing to Kolab account..."); + if (isset($destination->uri)) { + $this->debug("Importing to Kolab account..."); - $this->importer = new DAVClient($destination); + $this->importer = new DAVClient($destination); - foreach ($folders as $folder) { - $this->debug("Syncing folder {$folder['fullname']}..."); + // TODO: If we were to stay with this storage solution and need still + // the import mode, it should not require connecting again to + // Exchange. Now we do this for simplicity. + foreach ($folders as $folder) { + $this->debug("Syncing folder {$folder['fullname']}..."); - $this->importer->createFolder($folder['fullname'], $folder['type']); + $this->importer->createFolder($folder['fullname'], $folder['type']); - if ($folder['total'] > 0) { - $files = array_diff(scandir($folder['location']), ['.', '..']); - foreach ($files as $file) { - $this->debug("* Pushing item {$file}..."); - $this->importer->createObjectFromFile($folder['location'] . '/' . $file, $folder['fullname']); - // TODO: remove the file? + if ($folder['total'] > 0) { + $files = array_diff(scandir($folder['location']), ['.', '..']); + foreach ($files as $file) { + $this->debug("* Pushing item {$file}..."); + $this->importer->createObjectFromFile($folder['location'] . '/' . $file, $folder['fullname']); + // TODO: remove the file/folder? + } } } - } - $this->debug("Done."); + $this->debug("Done."); + } } /** * Autodiscover the server and authenticate the user */ protected function authenticate(string $user, string $password): void { // 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. $api = API\ExchangeAutodiscover::getAPI($user, $password); $server = $api->getClient()->getServer(); $version = $api->getClient()->getVersion(); $this->debug("Connected to $server ($version). Authenticating..."); $this->api = API::withUsernameAndPassword($server, $user, $password, [ 'version' => $version ]); } /** * Get folders hierarchy */ protected function getFolders(): array { // Get full folders hierarchy $options = [ 'Traversal' => 'Deep', ]; $folders = $this->api->getChildrenFolders('root', $options); $result = []; foreach ($folders as $folder) { $class = $folder->getFolderClass(); // Skip folder types we do not support if (!in_array($class, $this->folder_classes)) { continue; } $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] = [ 'id' => $folder->getFolderId(), 'total' => $folder->getTotalCount(), 'class' => $class, 'type' => array_key_exists($class, $this->type_map) ? $this->type_map[$class] : null, 'name' => $name, 'fullname' => $fullname, 'location' => $this->location . '/' . $fullname, ]; } return $result; } /** * Synchronize specified folder */ protected function syncItems(array $folder): void { $request = [ // Exchange's maximum is 1000 'IndexedPageItemView' => ['MaxEntriesReturned' => 100, 'Offset' => 0, 'BasePoint' => 'Beginning'], 'ParentFolderIds' => $folder['id']->toArray(true), '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); foreach ($response as $item) { $this->syncItem($item, $folder); } // Request other pages until we got all while (!$response->isIncludesLastItemInRange()) { $response = $this->api->getNextPage($response); foreach ($response as $item) { $this->syncItem($item, $folder); } } } /** * Synchronize specified object */ protected function syncItem(Type $item, array $folder): void { if ($driver = EWS\Item::factory($this, $item, $folder)) { $driver->syncItem($item); return; } // TODO IPM.Note (email) and IPM.StickyNote // Note: iTip messages in mail folders may have different class assigned // https://docs.microsoft.com/en-us/office/vba/outlook/Concepts/Forms/item-types-and-message-classes $this->debug("Unsupported object type: {$item->getItemClass()}. Skiped."); } } diff --git a/src/app/DataMigrator/EWS/Appointment.php b/src/app/DataMigrator/EWS/Appointment.php index 98799ab2..59ddfa60 100644 --- a/src/app/DataMigrator/EWS/Appointment.php +++ b/src/app/DataMigrator/EWS/Appointment.php @@ -1,92 +1,94 @@ 'calendar:UID']; return $request; } /** * Process event object */ protected function processItem(Type $item): bool { // Decode MIME content // TODO: Maybe find less-hacky way $content = $item->getMimeContent(); $ical = base64_decode((string) $content); + // 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); // 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($_attachment->getContent()); $body = rtrim(chunk_split($body, 74, "\r\n "), ' '); $ctype = $_attachment->getContentType(); // 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); } } // TODO: Maybe find less-hacky way $item->getMimeContent()->_ = $ical; return true; } /** * 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; } }