diff options
-rw-r--r-- | .eslintrc.js | 1 | ||||
-rw-r--r-- | composer.json | 15 | ||||
-rw-r--r-- | composer.lock | 407 | ||||
-rwxr-xr-x | lib/Controller/MessagesController.php | 13 | ||||
-rw-r--r-- | lib/IMAP/MessageMapper.php | 132 | ||||
-rw-r--r-- | lib/Integration/KItinerary/ItineraryExtractor.php | 88 | ||||
-rw-r--r-- | lib/Integration/Psr/LoggerAdapter.php | 158 | ||||
-rw-r--r-- | lib/Model/IMAPMessage.php | 3 | ||||
-rw-r--r-- | lib/Service/ItineraryService.php | 106 | ||||
-rw-r--r-- | package-lock.json | 43 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/components/Itinerary.vue | 71 | ||||
-rw-r--r-- | src/components/Message.vue | 9 | ||||
-rw-r--r-- | src/components/itinerary/CalendarImport.vue | 76 | ||||
-rw-r--r-- | src/components/itinerary/EventReservation.vue | 162 | ||||
-rw-r--r-- | src/components/itinerary/FlightReservation.vue | 177 | ||||
-rw-r--r-- | src/components/itinerary/TrainReservation.vue | 185 | ||||
-rw-r--r-- | src/service/DAVService.js | 3 | ||||
-rw-r--r-- | tests/Integration/KItinerary/ItineraryExtractorTest.php | 103 | ||||
-rw-r--r-- | tests/Service/ItineraryServiceTest.php | 138 | ||||
-rw-r--r-- | tests/Unit/Controller/MessagesControllerTest.php | 6 |
21 files changed, 1829 insertions, 68 deletions
diff --git a/.eslintrc.js b/.eslintrc.js index 7354f2593..ea38b3f81 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { expect: true, OC: true, OCA: true, + OCP: true, t: true, __webpack_public_path__: true, __webpack_nonce__: true, diff --git a/composer.json b/composer.json index 4eaa8ecbc..87a2e8202 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,14 @@ }, "require": { "php": ">=7.2", + "arthurhoaro/favicon": "^1.2", + "cerdic/css-tidy": "v1.7.1", + "christophwurst/kitinerary": "^0.2", + "christophwurst/kitinerary-bin": "^0.2", + "christophwurst/kitinerary-flatpak": "^0.2", + "ezyang/htmlpurifier": "4.12.0", + "gravatarphp/gravatar": "^2.0", + "kwi/urllinker": "dev-bleeding", "pear-pear.horde.org/horde_date": "^2.4.1@stable", "pear-pear.horde.org/horde_exception": "^2.0.8@stable", "pear-pear.horde.org/horde_imap_client": "^2.29.16@stable", @@ -29,13 +37,10 @@ "pear-pear.horde.org/horde_text_flowed": "^2.0.3@stable", "pear-pear.horde.org/horde_util": "^2.5.8@stable", "pear-pear.horde.org/horde_smtp": "^1.9.5@stable", - "cerdic/css-tidy": "v1.7.1", - "ezyang/htmlpurifier": "4.12.0", - "kwi/urllinker": "dev-bleeding", - "gravatarphp/gravatar": "^2.0", - "arthurhoaro/favicon": "^1.2" + "psr/log": "^1" }, "require-dev": { + "roave/security-advisories": "dev-master", "christophwurst/nextcloud": "v17.0.2", "christophwurst/nextcloud_testing": "0.9.1", "phan/phan": "^2.0" diff --git a/composer.lock b/composer.lock index 08595cb58..2605ef153 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ea48c3a9078d8b441b0d2078ff6039df", + "content-hash": "156f493170c7639a0e4cdcb0a6ecc92a", "packages": [ { "name": "arthurhoaro/favicon", @@ -102,6 +102,118 @@ "time": "2019-09-14T17:59:23+00:00" }, { + "name": "christophwurst/kitinerary", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/ChristophWurst/kitinerary.git", + "reference": "166abd7af84632130d8c74fd80b2225631ee95c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristophWurst/kitinerary/zipball/166abd7af84632130d8c74fd80b2225631ee95c6", + "reference": "166abd7af84632130d8c74fd80b2225631ee95c6", + "shasum": "" + }, + "require": { + "ext-json": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "ChristophWurst\\KItinerary\\": "/src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Christoph Wurst", + "email": "christoph@winzerhof-wurst.at" + } + ], + "description": "KItinerary adapter", + "time": "2019-12-04T14:51:41+00:00" + }, + { + "name": "christophwurst/kitinerary-bin", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/ChristophWurst/kitinerary-bin.git", + "reference": "76ad3cfcf6fd896e6d3ffc560ffa17bb8efbd7e4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristophWurst/kitinerary-bin/zipball/76ad3cfcf6fd896e6d3ffc560ffa17bb8efbd7e4", + "reference": "76ad3cfcf6fd896e6d3ffc560ffa17bb8efbd7e4", + "shasum": "" + }, + "require": { + "christophwurst/kitinerary": "^0.2.0", + "ext-json": "*", + "php": "^7.1", + "psr/log": "^1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "ChristophWurst\\KItinerary\\Bin\\": "/src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Christoph Wurst", + "email": "christoph@winzerhof-wurst.at" + } + ], + "description": "KItinerary binary executable", + "time": "2019-12-04T14:56:35+00:00" + }, + { + "name": "christophwurst/kitinerary-flatpak", + "version": "0.2.0", + "source": { + "type": "git", + "url": "https://github.com/ChristophWurst/kitinerary-flatpak.git", + "reference": "1bc6b8304c68f8656c5f1b7bf7e74a3c465e211c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ChristophWurst/kitinerary-flatpak/zipball/1bc6b8304c68f8656c5f1b7bf7e74a3c465e211c", + "reference": "1bc6b8304c68f8656c5f1b7bf7e74a3c465e211c", + "shasum": "" + }, + "require": { + "christophwurst/kitinerary": "0.2.0", + "ext-json": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "ChristophWurst\\KItinerary\\Flatpak\\": "/src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Christoph Wurst", + "email": "christoph@winzerhof-wurst.at" + } + ], + "description": "KItinerary Flatpak binding", + "time": "2019-12-04T14:55:14+00:00" + }, + { "name": "ezyang/htmlpurifier", "version": "v4.12.0", "source": { @@ -852,6 +964,53 @@ "LGPL-2.1" ], "description": "A library that provides functionality useful for all kind of applications." + }, + { + "name": "psr/log", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801", + "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2019-11-01T11:05:21+00:00" } ], "packages-dev": [ @@ -1972,16 +2131,16 @@ }, { "name": "phpunit/phpunit", - "version": "8.5.1", + "version": "8.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "7870c78da3c5e4883eaef36ae47853ebb3cb86f2" + "reference": "018b6ac3c8ab20916db85fa91bf6465acb64d1e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7870c78da3c5e4883eaef36ae47853ebb3cb86f2", - "reference": "7870c78da3c5e4883eaef36ae47853ebb3cb86f2", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/018b6ac3c8ab20916db85fa91bf6465acb64d1e0", + "reference": "018b6ac3c8ab20916db85fa91bf6465acb64d1e0", "shasum": "" }, "require": { @@ -2051,7 +2210,7 @@ "testing", "xunit" ], - "time": "2019-12-25T14:49:39+00:00" + "time": "2020-01-08T08:49:49+00:00" }, { "name": "psr/container", @@ -2103,51 +2262,224 @@ "time": "2017-02-14T16:28:37+00:00" }, { - "name": "psr/log", - "version": "1.1.2", + "name": "roave/security-advisories", + "version": "dev-master", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801" + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801", - "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389", + "reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389", "shasum": "" }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, + "conflict": { + "3f/pygmentize": "<1.2", + "adodb/adodb-php": "<5.20.12", + "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", + "amphp/artax": "<1.0.6|>=2,<2.0.6", + "amphp/http": "<1.0.1", + "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6", + "asymmetricrypt/asymmetricrypt": ">=0,<9.9.99", + "aws/aws-sdk-php": ">=3,<3.2.1", + "brightlocal/phpwhois": "<=4.2.5", + "bugsnag/bugsnag-laravel": ">=2,<2.0.2", + "cakephp/cakephp": ">=1.3,<1.3.18|>=2,<2.4.99|>=2.5,<2.5.99|>=2.6,<2.6.12|>=2.7,<2.7.6|>=3,<3.5.18|>=3.6,<3.6.15|>=3.7,<3.7.7", + "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cartalyst/sentry": "<=2.1.6", + "codeigniter/framework": "<=3.0.6", + "composer/composer": "<=1-alpha.11", + "contao-components/mediaelement": ">=2.14.2,<2.21.1", + "contao/core": ">=2,<3.5.39", + "contao/core-bundle": ">=4,<4.4.46|>=4.5,<4.8.6", + "contao/listing-bundle": ">=4,<4.4.8", + "datadog/dd-trace": ">=0.30,<0.30.2", + "david-garcia/phpwhois": "<=4.3.1", + "doctrine/annotations": ">=1,<1.2.7", + "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", + "doctrine/common": ">=2,<2.4.3|>=2.5,<2.5.1", + "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2", + "doctrine/doctrine-bundle": "<1.5.2", + "doctrine/doctrine-module": "<=0.7.1", + "doctrine/mongodb-odm": ">=1,<1.0.2", + "doctrine/mongodb-odm-bundle": ">=2,<3.0.1", + "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1", + "dompdf/dompdf": ">=0.6,<0.6.2", + "drupal/core": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1", + "drupal/drupal": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1", + "endroid/qr-code-bundle": "<3.4.2", + "erusev/parsedown": "<1.7.2", + "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.4", + "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.13.1|>=6,<6.7.9.1|>=6.8,<6.13.5.1|>=7,<7.2.4.1|>=7.3,<7.3.2.1", + "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.12.3|>=2011,<2017.12.4.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3", + "ezsystems/repository-forms": ">=2.3,<2.3.2.1", + "ezyang/htmlpurifier": "<4.1.1", + "firebase/php-jwt": "<2", + "fooman/tcpdf": "<6.2.22", + "fossar/tcpdf-parser": "<6.2.22", + "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", + "friendsofsymfony/user-bundle": ">=1.2,<1.3.5", + "fuel/core": "<1.8.1", + "gree/jose": "<=2.2", + "gregwar/rst": "<1.0.3", + "guzzlehttp/guzzle": ">=4-rc.2,<4.2.4|>=5,<5.3.1|>=6,<6.2.1", + "illuminate/auth": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.10", + "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30", + "illuminate/database": ">=4,<4.0.99|>=4.1,<4.1.29", + "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", + "ivankristianto/phpwhois": "<=4.3", + "james-heinrich/getid3": "<1.9.9", + "joomla/session": "<1.3.1", + "jsmitty12/phpwhois": "<5.1", + "kazist/phpwhois": "<=4.2.6", + "kreait/firebase-php": ">=3.2,<3.8.1", + "la-haute-societe/tcpdf": "<6.2.22", + "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30", + "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10", + "league/commonmark": "<0.18.3", + "magento/magento1ce": "<1.9.4.3", + "magento/magento1ee": ">=1,<1.14.4.3", + "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", + "monolog/monolog": ">=1.8,<1.12", + "namshi/jose": "<2.2", + "onelogin/php-saml": "<2.10.4", + "openid/php-openid": "<2.3", + "oro/crm": ">=1.7,<1.7.4", + "oro/platform": ">=1.7,<1.7.4", + "padraic/humbug_get_contents": "<1.1.2", + "pagarme/pagarme-php": ">=0,<3", + "paragonie/random_compat": "<2", + "paypal/merchant-sdk-php": "<3.12", + "pear/archive_tar": "<1.4.4", + "phpmailer/phpmailer": ">=5,<5.2.27|>=6,<6.0.6", + "phpoffice/phpexcel": "<=1.8.1", + "phpoffice/phpspreadsheet": "<=1.5", + "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3", + "phpwhois/phpwhois": "<=4.2.5", + "phpxmlrpc/extras": "<0.6.1", + "propel/propel": ">=2-alpha.1,<=2-alpha.7", + "propel/propel1": ">=1,<=1.7.1", + "pusher/pusher-php-server": "<2.2.1", + "robrichards/xmlseclibs": ">=1,<3.0.4", + "sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9", + "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", + "sensiolabs/connect": "<4.2.3", + "serluck/phpwhois": "<=4.2.6", + "shopware/shopware": "<5.3.7", + "silverstripe/cms": ">=3,<=3.0.11|>=3.1,<3.1.11", + "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", + "silverstripe/framework": ">=3,<3.6.7|>=3.7,<3.7.3|>=4,<4.4", + "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.1.2", + "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1", + "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4", + "silverstripe/userforms": "<3", + "simple-updates/phpwhois": "<=1", + "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4", + "simplesamlphp/simplesamlphp": "<1.17.8", + "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", + "slim/slim": "<2.6", + "smarty/smarty": "<3.1.33", + "socalnick/scn-social-auth": "<1.15.2", + "spoonity/tcpdf": "<6.2.22", + "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "stormpath/sdk": ">=0,<9.9.99", + "studio-42/elfinder": "<2.1.48", + "swiftmailer/swiftmailer": ">=4,<5.4.5", + "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", + "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", + "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", + "sylius/sylius": ">=1,<1.1.18|>=1.2,<1.2.17|>=1.3,<1.3.12|>=1.4,<1.4.4", + "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", + "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/mime": ">=4.3,<4.3.8", + "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/polyfill": ">=1,<1.10", + "symfony/polyfill-php55": ">=1,<1.10", + "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/routing": ">=2,<2.0.19", + "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<2.8.37|>=3,<3.3.17|>=3.4,<3.4.7|>=4,<4.0.7", + "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/serializer": ">=2,<2.0.11", + "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/translation": ">=2,<2.0.17", + "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", + "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", + "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", + "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7", + "tecnickcom/tcpdf": "<6.2.22", + "thelia/backoffice-default-template": ">=2.1,<2.1.2", + "thelia/thelia": ">=2.1-beta.1,<2.1.3", + "theonedemon/phpwhois": "<=4.2.5", + "titon/framework": ">=0,<9.9.99", + "truckersmp/phpwhois": "<=4.3.1", + "twig/twig": "<1.38|>=2,<2.7", + "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1", + "typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1", + "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5", + "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4", + "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", + "ua-parser/uap-php": "<3.8", + "wallabag/tcpdf": "<6.2.22", + "willdurand/js-translation-bundle": "<2.1.1", + "yiisoft/yii": ">=1.1.14,<1.1.15", + "yiisoft/yii2": "<2.0.15", + "yiisoft/yii2-bootstrap": "<2.0.4", + "yiisoft/yii2-dev": "<2.0.15", + "yiisoft/yii2-elasticsearch": "<2.0.5", + "yiisoft/yii2-gii": "<2.0.4", + "yiisoft/yii2-jui": "<2.0.4", + "yiisoft/yii2-redis": "<2.0.8", + "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", + "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-db": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.10|>=2.3,<2.3.5", + "zendframework/zend-developer-tools": ">=1.2.2,<1.2.3", + "zendframework/zend-diactoros": ">=1,<1.8.4", + "zendframework/zend-feed": ">=1,<2.10.3", + "zendframework/zend-form": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-http": ">=1,<2.8.1", + "zendframework/zend-json": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zend-ldap": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.8|>=2.3,<2.3.3", + "zendframework/zend-mail": ">=2,<2.4.11|>=2.5,<2.7.2", + "zendframework/zend-navigation": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-session": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.9|>=2.3,<2.3.4", + "zendframework/zend-validator": ">=2.3,<2.3.6", + "zendframework/zend-view": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-xmlrpc": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zendframework": "<2.5.1", + "zendframework/zendframework1": "<1.12.20", + "zendframework/zendopenid": ">=2,<2.0.2", + "zendframework/zendxml": ">=1,<1.0.1", + "zetacomponents/mail": "<1.8.2", + "zf-commons/zfc-user": "<1.2.2", + "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", + "zfr/zfr-oauth2-server-module": "<0.1.2" + }, + "type": "metapackage", "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "time": "2019-11-01T11:05:21+00:00" + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "time": "2020-01-06T19:16:46+00:00" }, { "name": "sabre/event", @@ -3274,6 +3606,7 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { + "kwi/urllinker": 20, "pear-pear.horde.org/horde_date": 0, "pear-pear.horde.org/horde_exception": 0, "pear-pear.horde.org/horde_imap_client": 0, @@ -3286,7 +3619,7 @@ "pear-pear.horde.org/horde_text_flowed": 0, "pear-pear.horde.org/horde_util": 0, "pear-pear.horde.org/horde_smtp": 0, - "kwi/urllinker": 20 + "roave/security-advisories": 20 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php index d4ca2c4a6..22b5d7b03 100755 --- a/lib/Controller/MessagesController.php +++ b/lib/Controller/MessagesController.php @@ -31,7 +31,6 @@ declare(strict_types=1); namespace OCA\Mail\Controller; use Exception; -use OCA\Mail\Account; use OCA\Mail\Contracts\IMailManager; use OCA\Mail\Contracts\IMailSearch; use OCA\Mail\Exception\ServiceException; @@ -40,6 +39,7 @@ use OCA\Mail\Http\HtmlResponse; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\IMailBox; +use OCA\Mail\Service\ItineraryService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -47,7 +47,6 @@ use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; -use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\Folder; use OCP\Files\IMimeTypeDetector; use OCP\IL10N; @@ -67,6 +66,9 @@ class MessagesController extends Controller { /** @var IMailSearch */ private $mailSearch; + /** @var ItineraryService */ + private $itineraryService; + /** @var string */ private $currentUserId; @@ -101,6 +103,7 @@ class MessagesController extends Controller { AccountService $accountService, IMailManager $mailManager, IMailSearch $mailSearch, + ItineraryService $itineraryService, string $UserId, $userFolder, ILogger $logger, @@ -112,6 +115,7 @@ class MessagesController extends Controller { $this->accountService = $accountService; $this->mailManager = $mailManager; $this->mailSearch = $mailSearch; + $this->itineraryService = $itineraryService; $this->currentUserId = $UserId; $this->userFolder = $userFolder; $this->logger = $logger; @@ -180,6 +184,11 @@ class MessagesController extends Controller { base64_decode($folderId), $id ); + $json['itineraries'] = $this->itineraryService->extract( + $account, + base64_decode($folderId), + $id + ); $json['attachments'] = array_map(function ($a) use ($accountId, $folderId, $id) { return $this->enrichDownloadUrl($accountId, $folderId, $id, $a); }, $json['attachments']); diff --git a/lib/IMAP/MessageMapper.php b/lib/IMAP/MessageMapper.php index 93c4376a9..f42f8bc35 100644 --- a/lib/IMAP/MessageMapper.php +++ b/lib/IMAP/MessageMapper.php @@ -31,11 +31,13 @@ use Horde_Imap_Client_Fetch_Query; use Horde_Imap_Client_Ids; use Horde_Imap_Client_Socket; use Horde_Mime_Mail; +use Horde_Mime_Part; use OCA\Mail\Db\Mailbox; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Model\IMAPMessage; use OCP\AppFramework\Db\DoesNotExistException; use OCP\ILogger; +use function iterator_to_array; class MessageMapper { @@ -214,4 +216,134 @@ class MessageMapper { ); } + public function getHtmlBody(Horde_Imap_Client_Socket $client, + string $mailbox, + int $id): ?string { + $messageQuery = new Horde_Imap_Client_Fetch_Query(); + $messageQuery->envelope(); + $messageQuery->structure(); + + $result = $client->fetch($mailbox, $messageQuery, [ + 'ids' => new Horde_Imap_Client_Ids([$id]), + ]); + + if (($message = $result->first()) === null) { + throw new DoesNotExistException('Message does not exist'); + } + + $structure = $message->getStructure(); + $htmlPartId = $structure->findBody('html'); + if ($htmlPartId === null) { + // No HTML part + return null; + } + $partsQuery = new Horde_Imap_Client_Fetch_Query(); + $partsQuery->fullText(); + foreach ($structure->partIterator() as $structurePart) { + /** @var Horde_Mime_Part $structurePart */ + $partsQuery->bodyPart($structurePart->getMimeId(), [ + 'decode' => true, + 'peek' => true, + ]); + $partsQuery->bodyPartSize($structurePart->getMimeId()); + if ($structurePart->getMimeId() === $htmlPartId) { + $partsQuery->mimeHeader($structurePart->getMimeId(), [ + 'peek' => true + ]); + } + + } + + $parts = $client->fetch($mailbox, $partsQuery, [ + 'ids' => new Horde_Imap_Client_Ids([$id]), + ]); + + foreach ($parts as $part) { + /** @var Horde_Imap_Client_Data_Fetch $part */ + $stream = $part->getBodyPart($htmlPartId, true); + $partData = $structure->getPart($htmlPartId); + $partData->setContents($stream, [ + 'usestream' => true, + ]); + + $body = $part->getBodyPart($htmlPartId); + if ($body !== null) { + $structurePart = $structure[$htmlPartId]; + $mimeHeaders = $part->getMimeHeader($htmlPartId, Horde_Imap_Client_Data_Fetch::HEADER_PARSE); + if ($enc = $mimeHeaders->getValue('content-transfer-encoding')) { + $structure->setTransferEncoding($enc); + } + $structure->setContents($body); + $decoded = $structure->getContents(); + + return $decoded; + } + } + + return null; + } + + public function getRawAttachments(Horde_Imap_Client_Socket $client, + string $mailbox, + int $id): array { + $messageQuery = new Horde_Imap_Client_Fetch_Query(); + $messageQuery->structure(); + + $result = $client->fetch($mailbox, $messageQuery, [ + 'ids' => new Horde_Imap_Client_Ids([$id]), + ]); + + if (($structureResult = $result->first()) === null) { + throw new DoesNotExistException('Message does not exist'); + } + + $structure = $structureResult->getStructure(); + $partsQuery = new Horde_Imap_Client_Fetch_Query(); + $partsQuery->fullText(); + foreach ($structure->partIterator() as $part) { + /** @var Horde_Mime_Part $part */ + if ($part->getMimeId() === "0") { + // Ignore message header + continue; + } + + $partsQuery->bodyPart($part->getMimeId(), [ + 'peek' => true, + ]); + $partsQuery->mimeHeader($part->getMimeId(), [ + 'peek' => true + ]); + $partsQuery->bodyPartSize($part->getMimeId()); + } + + $parts = $client->fetch($mailbox, $partsQuery, [ + 'ids' => new Horde_Imap_Client_Ids([$id]), + ]); + if (($messageData = $parts->first()) === null) { + throw new DoesNotExistException('Message does not exist'); + } + + $attachments = []; + foreach ($structure->partIterator() as $key => $part) { + /** @var Horde_Mime_Part $part */ + + if (!$part->isAttachment()) { + continue; + } + + $stream = $messageData->getBodyPart($key, true); + $mimeHeaders = $messageData->getMimeHeader($key, Horde_Imap_Client_Data_Fetch::HEADER_PARSE); + if ($enc = $mimeHeaders->getValue('content-transfer-encoding')) { + $part->setTransferEncoding($enc); + } + $part->setContents($stream, [ + 'usestream' => true, + ]); + $decoded = $part->getContents(); + + $attachments[] = $decoded; + } + return $attachments; + } + } diff --git a/lib/Integration/KItinerary/ItineraryExtractor.php b/lib/Integration/KItinerary/ItineraryExtractor.php new file mode 100644 index 000000000..9e6e6e932 --- /dev/null +++ b/lib/Integration/KItinerary/ItineraryExtractor.php @@ -0,0 +1,88 @@ +<?php declare(strict_types=1); + +/** + * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Integration\KItinerary; + +use ChristophWurst\KItinerary\Adapter; +use ChristophWurst\KItinerary\Exception\KItineraryRuntimeException; +use ChristophWurst\KItinerary\Flatpak\FlatpakAdapter; +use ChristophWurst\KItinerary\Itinerary; +use ChristophWurst\KItinerary\ItineraryExtractor as Extractor; +use ChristophWurst\KItinerary\Bin\BinaryAdapter; +use OCA\Mail\Integration\Psr\LoggerAdapter; +use OCP\ILogger; + +class ItineraryExtractor { + + /** @var BinaryAdapter */ + private $binAdapter; + + /** @var FlatpakAdapter */ + private $flatpakAdapter; + + /** @var ILogger */ + private $logger; + + /** @var Adapter */ + private $adapter; + + public function __construct(BinaryAdapter $binAdapter, + FlatpakAdapter $flatpakAdapter, + ILogger $logger) { + $this->binAdapter = $binAdapter; + $this->flatpakAdapter = $flatpakAdapter; + $this->logger = $logger; + } + + private function findAvailableAdapter(): ?Adapter { + if ($this->binAdapter->isAvailable()) { + $this->binAdapter->setLogger(new LoggerAdapter($this->logger)); + return $this->binAdapter; + } + if ($this->flatpakAdapter->isAvailable()) { + return $this->flatpakAdapter; + } + return null; + } + + public function extract(string $content): Itinerary { + if ($this->adapter === null) { + $this->adapter = $this->findAvailableAdapter() ?? false; + } + if ($this->adapter === false) { + $this->logger->warning('KItinerary binary adapter is not available, can\'t extract information'); + + return new Itinerary(); + } + + try { + return (new Extractor($this->adapter))->extractFromString($content); + } catch (KItineraryRuntimeException $e) { + $this->logger->logException($e, [ + 'message' => 'Could not extract itinerary function from KItinerary integration', + ]); + return new Itinerary(); + } + } + +} diff --git a/lib/Integration/Psr/LoggerAdapter.php b/lib/Integration/Psr/LoggerAdapter.php new file mode 100644 index 000000000..34ad54558 --- /dev/null +++ b/lib/Integration/Psr/LoggerAdapter.php @@ -0,0 +1,158 @@ +<?php declare(strict_types=1); + +/** + * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Integration\Psr; + +use OCP\ILogger; +use Psr\Log\LoggerInterface; + +class LoggerAdapter implements LoggerInterface { + + /** @var ILogger */ + private $logger; + + public function __construct(ILogger $logger) { + $this->logger = $logger; + } + + /** + * System is unusable. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function emergency($message, array $context = array()) { + $this->logger->emergency($message, $context); + } + + /** + * Action must be taken immediately. + * + * Example: Entire website down, database unavailable, etc. This should + * trigger the SMS alerts and wake you up. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function alert($message, array $context = array()) { + $this->logger->alert($message, $context); + } + + /** + * Critical conditions. + * + * Example: Application component unavailable, unexpected exception. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function critical($message, array $context = array()) { + $this->logger->critical($message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically + * be logged and monitored. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function error($message, array $context = array()) { + $this->logger->error($message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * Example: Use of deprecated APIs, poor use of an API, undesirable things + * that are not necessarily wrong. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function warning($message, array $context = array()) { + $this->logger->warning($message, $context); + } + + /** + * Normal but significant events. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function notice($message, array $context = array()) { + $this->logger->notice($message, $context); + } + + /** + * Interesting events. + * + * Example: User logs in, SQL logs. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function info($message, array $context = array()) { + $this->logger->info($message, $context); + } + + /** + * Detailed debug information. + * + * @param string $message + * @param array $context + * + * @return void + */ + public function debug($message, array $context = array()) { + $this->logger->debug($message, $context); + } + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + * + * @return void + */ + public function log($level, $message, array $context = array()) { + $this->logger->log($level, $message, $context); + } + +} diff --git a/lib/Model/IMAPMessage.php b/lib/Model/IMAPMessage.php index 74ec3aa04..c37451ba1 100644 --- a/lib/Model/IMAPMessage.php +++ b/lib/Model/IMAPMessage.php @@ -29,7 +29,6 @@ declare(strict_types=1); namespace OCA\Mail\Model; -use Closure; use Exception; use Horde_Imap_Client; use Horde_Imap_Client_Data_Envelope; @@ -48,7 +47,6 @@ use OCA\Mail\Service\Html; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Files\File; use OCP\Files\SimpleFS\ISimpleFile; -use OCP\Util; use function base64_encode; use function mb_convert_encoding; @@ -413,6 +411,7 @@ class IMAPMessage implements IMessage, JsonSerializable { public function jsonSerialize(): array { return [ 'id' => $this->getUid(), + 'messageId' => $this->getMessageId(), 'from' => $this->getFrom()->jsonSerialize(), 'to' => $this->getTo()->jsonSerialize(), 'cc' => $this->getCC()->jsonSerialize(), diff --git a/lib/Service/ItineraryService.php b/lib/Service/ItineraryService.php new file mode 100644 index 000000000..a318bf4e4 --- /dev/null +++ b/lib/Service/ItineraryService.php @@ -0,0 +1,106 @@ +<?php declare(strict_types=1); + +/** + * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Service; + +use ChristophWurst\KItinerary\Itinerary; +use OCA\Mail\Account; +use OCA\Mail\Db\MailboxMapper; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Integration\KItinerary\ItineraryExtractor; +use OCP\ICacheFactory; +use OCP\ILogger; +use function array_reduce; +use function count; +use function json_encode; + +class ItineraryService { + + /** @var IMAPClientFactory */ + private $clientFactory; + + /** @var MailboxMapper */ + private $mailboxMapper; + + /** @var MessageMapper */ + private $messageMapper; + + /** @var ItineraryExtractor */ + private $extractor; + + /** @var ILogger */ + private $logger; + + public function __construct(IMAPClientFactory $clientFactory, + MailboxMapper $mailboxMapper, + MessageMapper $messageMapper, + ItineraryExtractor $extractor, + ICacheFactory $cacheFactory, + ILogger $logger) { + $this->clientFactory = $clientFactory; + $this->mailboxMapper = $mailboxMapper; + $this->messageMapper = $messageMapper; + $this->extractor = $extractor; + $this->cache = $cacheFactory->createLocal(); + $this->logger = $logger; + } + + public function extract(Account $account, string $mailbox, int $id): Itinerary { + $mailbox = $this->mailboxMapper->find($account, $mailbox); + + $cacheKey = 'mail_itinerary_' . $account->getId() . '_' . $mailbox->getMailbox() . '_' . $id; + if ($cached = ($this->cache->get($cacheKey))) { + return Itinerary::fromJson($cached); + } + + $client = $this->clientFactory->getClient($account); + + $itinerary = new Itinerary(); + $htmlBody = $this->messageMapper->getHtmlBody($client, $mailbox->getMailbox(), $id); + if ($htmlBody !== null) { + $itinerary = $itinerary->merge( + $this->extractor->extract($htmlBody) + ); + $this->logger->debug('Extracted ' . count($itinerary) . ' itinerary entries from the message HTML body'); + } else { + $this->logger->debug('Message does not have an HTML body, can\'t extract itinerary info'); + } + $attachments = $this->messageMapper->getRawAttachments($client, $mailbox->getMailbox(), $id); + $itinerary = array_reduce($attachments, function(Itinerary $combined, string $attachment) { + $extracted = $this->extractor->extract($attachment); + $this->logger->debug('Extracted ' . count($extracted) . ' itinerary entries from an attachment'); + return $combined->merge($extracted); + }, $itinerary); + + // Lastly, we put the extracted data through the tool again, so it can combine + // and pick the most relevant information + $final = $this->extractor->extract(json_encode($itinerary)); + $this->logger->debug('Reduced ' . count($itinerary) . ' itinerary entries to ' . count($final) . ' entries'); + + $this->cache->set($cacheKey, json_encode($final)); + + return $final; + } + +} diff --git a/package-lock.json b/package-lock.json index 07bb306bd..ae76deeec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -171,7 +171,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" } } @@ -4549,7 +4549,7 @@ }, "json5": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/json5/-/json5-1.0.1.tgz", "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", "dev": true, "requires": { @@ -4569,7 +4569,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, @@ -7101,7 +7101,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" } } @@ -7121,7 +7121,7 @@ "dependencies": { "readable-stream": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-3.3.0.tgz", "integrity": "sha512-EsI+s3k3XsW+fU8fQACLN59ky34AZ14LoeVZpYwmZvldCFo0r0gnelwF2TcMjLor/BTL5aDJVBMkss0dthToPw==", "requires": { "inherits": "^2.0.3", @@ -7813,7 +7813,7 @@ }, "json5": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" }, "jsonfile": { @@ -8149,7 +8149,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true } @@ -8268,7 +8268,7 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "mississippi": { @@ -10652,7 +10652,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -11068,7 +11068,7 @@ }, "yargs": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", "dev": true, "requires": { @@ -11693,7 +11693,7 @@ }, "strip-ansi": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", "requires": { "ansi-regex": "^3.0.0" @@ -11711,7 +11711,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -12603,6 +12603,11 @@ "vue-style-loader": "^4.1.0" } }, + "vue-material-design-icons": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.3.0.tgz", + "integrity": "sha512-ohKKNFBFSf31VDy+/xuEleP0IMGeAvR4IA7V7b5ak//TOzgvQFwFXu+uJGvkvNoldlXYdGy5X3+4Qil8ozf8JA==" + }, "vue-multiselect": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz", @@ -12845,7 +12850,7 @@ }, "json5": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/json5/-/json5-1.0.1.tgz", "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", "dev": true, "requires": { @@ -12865,7 +12870,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, @@ -12969,7 +12974,7 @@ }, "json5": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/json5/-/json5-1.0.1.tgz", "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", "dev": true, "requires": { @@ -12999,7 +13004,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true }, @@ -13081,7 +13086,7 @@ }, "yargs": { "version": "13.2.4", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.2.4.tgz", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-13.2.4.tgz", "integrity": "sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg==", "dev": true, "requires": { @@ -13206,7 +13211,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "dev": true, "requires": { @@ -13401,7 +13406,7 @@ }, "wrap-ansi": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", "dev": true, "requires": { diff --git a/package.json b/package.json index 6cb2e2816..cb4c74199 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "vue-autosize": "^1.0.2", "vue-click-outside": "^1.0.7", "vue-infinite-scroll": "^2.0.2", + "vue-material-design-icons": "^4.3.0", "vue-on-click-outside": "^1.0.3", "vue-router": "^3.1.3", "vue-scroll": "^2.1.13", diff --git a/src/components/Itinerary.vue b/src/components/Itinerary.vue new file mode 100644 index 000000000..ee4a76124 --- /dev/null +++ b/src/components/Itinerary.vue @@ -0,0 +1,71 @@ +<template> + <div> + <template v-for="(entry, idx) in entries"> + <EventReservation + v-if="entry['@type'] === 'EventReservation'" + :key="idx" + :data="entry" + :calendars="calendars" + :message-id="messageId" + /> + <FlightReservation + v-else-if="entry['@type'] === 'FlightReservation'" + :key="idx" + :data="entry" + :calendars="calendars" + :message-id="messageId" + /> + <TrainReservation + v-else-if="entry['@type'] === 'TrainReservation'" + :key="idx" + :data="entry" + :calendars="calendars" + :message-id="messageId" + /> + <span v-else :key="idx">{{ + t('mail', 'Itinerary for {type} is not supported yet', {type: entry['@type']}) + }}</span> + </template> + </div> +</template> + +<script> +import once from 'lodash/fp/once' + +import {getUserCalendars} from '../service/DAVService' +import logger from '../logger' +import EventReservation from './itinerary/EventReservation' +import FlightReservation from './itinerary/FlightReservation' +import TrainReservation from './itinerary/TrainReservation' + +const getUserCalendarsOnce = once(getUserCalendars) + +export default { + name: 'Itinerary', + components: { + EventReservation, + FlightReservation, + TrainReservation, + }, + props: { + entries: { + type: Array, + required: true, + }, + messageId: { + type: String, + required: true, + }, + }, + data() { + return { + calendars: [], + } + }, + mounted() { + getUserCalendarsOnce() + .then(calendars => (this.calendars = calendars)) + .catch(error => logger.error('Could not load calendars', {error})) + }, +} +</script> diff --git a/src/components/Message.vue b/src/components/Message.vue index f89ed6790..886d31fcf 100644 --- a/src/components/Message.vue +++ b/src/components/Message.vue @@ -53,6 +53,9 @@ </div> </div> <div class="mail-message-body"> + <div v-if="message.itineraries.length > 0" class="message-itinerary"> + <Itinerary :entries="message.itineraries" :message-id="message.messageId" /> + </div> <MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" /> <MessagePlainTextBody v-else :body="message.body" :signature="message.signature" /> <MessageAttachments :attachments="message.attachments" /> @@ -72,6 +75,7 @@ import AddressList from './AddressList' import {buildRecipients as buildReplyRecipients, buildReplySubject} from '../ReplyBuilder' import Error from './Error' import {getRandomMessageErrorMessage} from '../util/ErrorMessageFactory' +import Itinerary from './Itinerary' import MessageHTMLBody from './MessageHTMLBody' import MessagePlainTextBody from './MessagePlainTextBody' import Loading from './Loading' @@ -86,6 +90,7 @@ export default { AddressList, AppContentDetails, Error, + Itinerary, Loading, MessageAttachments, MessageHTMLBody, @@ -316,7 +321,7 @@ export default { #mail-content, .mail-message-attachments { - margin: 10px 10px 50px 38px; + margin: 10px 38px 50px 38px; } .mail-message-attachments { @@ -344,7 +349,7 @@ export default { flex-direction: row; justify-content: flex-end; margin-left: 10px; - margin-right: 35px; + margin-right: 22px; height: 44px; } diff --git a/src/components/itinerary/CalendarImport.vue b/src/components/itinerary/CalendarImport.vue new file mode 100644 index 000000000..7cbe052c4 --- /dev/null +++ b/src/components/itinerary/CalendarImport.vue @@ -0,0 +1,76 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <Actions v-if="calendars.length" default-icon="icon-add"> + <ActionButton + v-for="(calendar, idx) in cals" + :key="idx" + :icon="calendar.loading ? 'icon-loading-small' : 'icon-add'" + @click="onImport(calendar)" + >{{ t('mail', 'Import into {calendar}', {calendar: calendar.displayname}) }}</ActionButton + > + </Actions> +</template> + +<script> +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' + +export default { + name: 'CalendarImport', + components: { + Actions, + ActionButton, + }, + props: { + calendars: { + type: Array, + required: true, + }, + handler: { + type: Function, + required: true, + }, + }, + computed: { + cals() { + return this.calendars.map(original => { + this.$set(original, 'loading', false) + return original + }) + }, + }, + methods: { + onImport(calendar) { + calendar.loading = true + + this.handler(calendar) + .catch(console.error.bind(this)) + .then(() => { + calendar.loading = false + }) + }, + }, +} +</script> + +<style scoped></style> diff --git a/src/components/itinerary/EventReservation.vue b/src/components/itinerary/EventReservation.vue new file mode 100644 index 000000000..8de523491 --- /dev/null +++ b/src/components/itinerary/EventReservation.vue @@ -0,0 +1,162 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div class="reservation"> + <div class="event"> + <div class="event-name">{{ eventName }}</div> + <div v-if="location" class="venue">{{ location }}</div> + <div v-if="date">{{ date }}</div> + <div v-if="time">{{ time }}</div> + </div> + <CalendarImport v-if="canImport" :calendars="calendars" :handler="handleImport" /> + </div> +</template> + +<script> +import ical from 'ical.js' +import md5 from 'md5' +import moment from '@nextcloud/moment' + +import CalendarImport from './CalendarImport' +import {importCalendarEvent, importSingleCalendarEvent} from '../../service/DAVService' +import logger from '../../logger' + +export default { + name: 'EventReservation', + components: {CalendarImport}, + props: { + data: { + type: Object, + required: true, + }, + calendars: { + type: Array, + required: true, + }, + messageId: { + type: String, + required: true, + }, + }, + computed: { + eventName() { + return this.data.reservationFor.name + }, + time() { + if (!('startDate' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.startDate).format('LT') + }, + date() { + if (!('startDate' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.startDate).format('L') + }, + location() { + if (!('location' in this.data.reservationFor) || !('name' in this.data.reservationFor.location)) { + return + } + return this.data.reservationFor.location.name + }, + canImport() { + return 'startDate' in this.data.reservationFor + }, + }, + methods: { + getEndDateTime(event) { + if ('endDate' in this.data.reservationFor) { + return moment(this.data.reservationFor.endDate).format() + } else { + // Assume it's 2h and user will adjust if necessary + // TODO: handle 'duration' https://schema.org/Event + return moment('2019-10-22T12:00:00Z') + .add(2, 'hours') + .format() + } + }, + handleImport(calendar) { + const event = new ical.Component('VEVENT') + event.updatePropertyWithValue('SUMMARY', this.eventName) + + const start = moment(this.data.reservationFor.startDate).format() + event.updatePropertyWithValue('DTSTART', ical.Time.fromDateTimeString(start)) + event.updatePropertyWithValue( + 'DTEND', + ical.Time.fromDateTimeString(this.getEndDateTime(this.data.reservationFor)) + ) + + if ('location' in this.data.reservationFor) { + event.updatePropertyWithValue('LOCATION', this.data.reservationFor.location.name) + if ('geo' in this.data.reservationFor.location) { + // https://www.kanzaki.com/docs/ical/geo.html + event.updatePropertyWithValue( + 'GEO', + `${this.data.reservationFor.location.geo.latitude};${this.data.reservationFor.location.geo.longitude}` + ) + } + } + + // TODO: read version from package.json + event.updatePropertyWithValue('PRODID', 'Nextcloud Mail') + + // TODO: is this free of collisions? the bug reports will tell us! + event.updatePropertyWithValue('UID', md5(this.messageId + this.eventName)) + + const cal = new ical.Component('VCALENDAR') + cal.addSubcomponent(event) + logger.debug('generated calendar event from event reservation data', {ical: cal.toString()}) + return importCalendarEvent(calendar.url)(cal.toString()) + .then(() => { + logger.debug('event successfully imported') + OCP.Toast.success(t('mail', 'Event imported into {calendar}', {calendar: calendar.displayname})) + }) + .catch(error => { + logger.error('Could not import event', {error}) + OCP.Toast.error(t('mail', 'Could not create event')) + }) + }, + }, +} +</script> + +<style scoped> +.reservation { + display: flex; + flex-direction: row; + margin: 30px 38px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: 20px; + align-items: center; +} + +.event { + flex-grow: 1; +} + +.event-name { + font-size: larger; + font-weight: bold; +} +</style> diff --git a/src/components/itinerary/FlightReservation.vue b/src/components/itinerary/FlightReservation.vue new file mode 100644 index 000000000..7d903b95b --- /dev/null +++ b/src/components/itinerary/FlightReservation.vue @@ -0,0 +1,177 @@ +<template> + <div class="reservation"> + <div class="departure"> + <div class="iata">{{ data.reservationFor.departureAirport.iataCode }}</div> + <div class="airport">{{ data.reservationFor.departureAirport.name }}</div> + <div v-if="departureDate">{{ departureDate }}</div> + <div v-if="departureTime">{{ departureTime }}</div> + </div> + <div class="connection"> + <div><AirplaneIcon :title="t('mail', 'Airplane')" /></div> + <div>{{ flightNumber }}</div> + <div v-if="reservation">{{ t('mail', 'Reservation {id}', {id: reservation}) }}</div> + <div v-else><ArrowIcon decorative /></div> + </div> + <div class="arrival"> + <div class="iata">{{ data.reservationFor.arrivalAirport.iataCode }}</div> + <div class="airport">{{ data.reservationFor.arrivalAirport.name }}</div> + <div v-if="arrivalDate">{{ arrivalDate }}</div> + <div v-if="arrivalTime">{{ arrivalTime }}</div> + </div> + <CalendarImport :calendars="calendars" :handler="handleImport" /> + </div> +</template> + +<script> +import AirplaneIcon from 'vue-material-design-icons/Airplane' +import ArrowIcon from 'vue-material-design-icons/ArrowRight' +import ical from 'ical.js' +import md5 from 'md5' +import moment from '@nextcloud/moment' + +import CalendarImport from './CalendarImport' +import {importCalendarEvent} from '../../service/DAVService' +import logger from '../../logger' + +export default { + name: 'FlightReservation', + components: { + AirplaneIcon, + ArrowIcon, + CalendarImport, + }, + props: { + data: { + type: Object, + required: true, + }, + calendars: { + type: Array, + required: true, + }, + messageId: { + type: String, + required: true, + }, + }, + computed: { + departureTime() { + if (!('departureTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.departureTime['@value']).format('LT') + }, + departureDate() { + if (!('departureTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.departureTime['@value']).format('L') + }, + arrivalTime() { + if (!('arrivalTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.arrivalTime['@value']).format('LT') + }, + arrivalDate() { + if (!('arrivalTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.arrivalTime['@value']).format('L') + }, + flightNumber() { + return this.data.reservationFor.airline.iataCode + this.data.reservationFor.flightNumber + }, + reservation() { + if (!('reservationNumber' in this.data)) { + return + } + return this.data.reservationNumber + }, + canImport() { + return 'departureTime' in this.data.reservationFor && 'arrivalTime' in this.data.reservationFor + }, + }, + methods: { + handleImport(calendar) { + const event = new ical.Component('VEVENT') + event.updatePropertyWithValue( + 'SUMMARY', + t('mail', 'Flight {flightNr} from {depAirport} to {arrAirport}', { + flightNr: this.flightNumber, + depAirport: this.data.reservationFor.departureAirport.iataCode, + arrAirport: this.data.reservationFor.arrivalAirport.iataCode, + }) + ) + + const depart = moment(this.data.reservationFor.departureTime['@value']).format() + event.updatePropertyWithValue('DTSTART', ical.Time.fromDateTimeString(depart)) + const arrive = moment(this.data.reservationFor.arrivalTime['@value']).format() + event.updatePropertyWithValue('DTEND', ical.Time.fromDateTimeString(arrive)) + + // TODO: read version from package.json + event.updatePropertyWithValue('PRODID', 'Nextcloud Mail') + + // TODO: is this free of collisions? the bug reports will tell us! + event.updatePropertyWithValue('UID', md5(this.messageId + this.flightNumber)) + + const cal = new ical.Component('VCALENDAR') + cal.addSubcomponent(event) + logger.debug('generated calendar event from flight reservation data', {ical: cal.toString()}) + + return importCalendarEvent(calendar.url)(cal.toString()) + .then(() => { + logger.debug('event successfully imported') + OCP.Toast.success(t('mail', 'Event imported into {calendar}', {calendar: calendar.displayname})) + }) + .catch(error => { + logger.error('Could not import event', {error}) + OCP.Toast.error(t('mail', 'Could not create event')) + }) + }, + }, +} +</script> + +<style scoped> +.reservation { + display: flex; + flex-direction: row; + margin: 30px 38px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: 20px; + align-items: center; +} + +.departure, +.arrival { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.departure, +.arrival, +.connection { + justify-content: center; +} + +.iata { + font-size: larger; + font-weight: bold; +} + +.airport { + font-size: large; +} + +.departure { + text-align: right; +} + +.connection { + text-align: center; + padding: 0 40px; +} +</style> diff --git a/src/components/itinerary/TrainReservation.vue b/src/components/itinerary/TrainReservation.vue new file mode 100644 index 000000000..b16f1fbe4 --- /dev/null +++ b/src/components/itinerary/TrainReservation.vue @@ -0,0 +1,185 @@ +<template> + <div class="reservation"> + <div class="departure"> + <div class="station">{{ data.reservationFor.departureStation.name }}</div> + <div v-if="departureDate">{{ departureDate }}</div> + <div v-if="departureTime">{{ departureTime }}</div> + </div> + <div class="connection"> + <div><TrainIcon :title="t('mail', 'Tain')" /></div> + <div>{{ trainNumber }}</div> + <div><ArrowIcon decorative /></div> + </div> + <div class="arrival"> + <div class="station">{{ data.reservationFor.arrivalStation.name }}</div> + <div v-if="arrivalDate">{{ arrivalDate }}</div> + <div v-if="arrivalTime">{{ arrivalTime }}</div> + </div> + <CalendarImport v-if="canImport" :calendars="calendars" :handler="handleImport" /> + </div> +</template> + +<script> +import ArrowIcon from 'vue-material-design-icons/ArrowRight' +import ical from 'ical.js' +import md5 from 'md5' +import moment from '@nextcloud/moment' +import TrainIcon from 'vue-material-design-icons/Train' + +import CalendarImport from './CalendarImport' +import {importCalendarEvent} from '../../service/DAVService' +import logger from '../../logger' + +export default { + name: 'TrainReservation', + components: { + ArrowIcon, + CalendarImport, + TrainIcon, + }, + props: { + data: { + type: Object, + required: true, + }, + calendars: { + type: Array, + required: true, + }, + messageId: { + type: String, + required: true, + }, + }, + computed: { + departureTime() { + if (!('departureTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.departureTime).format('LT') + }, + departureDate() { + if ('departureTime' in this.data.reservationFor) { + return moment(this.data.reservationFor.departureTime).format('L') + } + if ('departureDay' in this.data.reservationFor) { + return moment(this.data.reservationFor.departureDay).format('L') + } + return undefined + }, + arrivalTime() { + if (!('arrivalTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.arrivalTime).format('LT') + }, + arrivalDate() { + if (!('arrivalTime' in this.data.reservationFor)) { + return + } + return moment(this.data.reservationFor.arrivalTime).format('L') + }, + trainNumber() { + return this.data.reservationFor.trainNumber + }, + canImport() { + return ( + ('departureTime' in this.data.reservationFor && 'arrivalTime' in this.data.reservationFor) || + 'departureDay' in this.data.reservationFor + ) + }, + }, + methods: { + handleImport(calendar) { + const event = new ical.Component('VEVENT') + if ('trainNumber' in this.data.reservationFor) { + event.updatePropertyWithValue( + 'SUMMARY', + t('mail', '{trainNr} from {depStation} to {arrStation}', { + trainNr: this.data.reservationFor.trainNumber, + depStation: this.data.reservationFor.departureStation.name, + arrStation: this.data.reservationFor.arrivalStation.name, + }) + ) + } else { + event.updatePropertyWithValue( + 'SUMMARY', + t('mail', 'Train from {depStation} to {arrStation}', { + depStation: this.data.reservationFor.departureStation.name, + arrStation: this.data.reservationFor.arrivalStation.name, + }) + ) + } + + if ('departureTime' in this.data.reservationFor && 'arrivalTime' in this.data.reservationFor) { + const depart = moment(this.data.reservationFor.departureTime).format() + event.updatePropertyWithValue('DTSTART', ical.Time.fromDateTimeString(depart)) + const arrive = moment(this.data.reservationFor.arrivalTime).format() + event.updatePropertyWithValue('DTEND', ical.Time.fromDateTimeString(arrive)) + } else if ('departureDay' in this.data.reservationFor) { + const date = moment(this.data.reservationFor.departureDay).format() + event.updatePropertyWithValue('DTSTART', ical.Time.fromDateTimeString(date)) + } + + // TODO: read version from package.json + event.updatePropertyWithValue('PRODID', 'Nextcloud Mail') + + // TODO: is this free of collisions? the bug reports will tell us! + event.updatePropertyWithValue('UID', md5(this.messageId + this.departureTime)) + + const cal = new ical.Component('VCALENDAR') + cal.addSubcomponent(event) + logger.debug('generated calendar event from train reservation data', {ical: cal.toString()}) + + return importCalendarEvent(calendar.url)(cal.toString()) + .then(() => { + logger.debug('event successfully imported') + OCP.Toast.success(t('mail', 'Event imported into {calendar}', {calendar: calendar.displayname})) + }) + .catch(error => { + logger.error('Could not import event', {error}) + OCP.Toast.error(t('mail', 'Could not create event')) + }) + }, + }, +} +</script> + +<style scoped> +.reservation { + display: flex; + flex-direction: row; + margin: 30px 38px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: 20px; + align-items: center; +} + +.departure, +.arrival { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.departure, +.arrival, +.connection { + justify-content: center; +} + +.station { + font-size: larger; + font-weight: bold; +} + +.departure { + text-align: right; +} + +.connection { + text-align: center; + padding: 0 40px; +} +</style> diff --git a/src/service/DAVService.js b/src/service/DAVService.js index b59d484b7..8682aeae6 100644 --- a/src/service/DAVService.js +++ b/src/service/DAVService.js @@ -191,7 +191,7 @@ const splitCalendar = data => { * @returns {Promise} */ export const importCalendarEvent = url => data => { - Logger.debug('importing event into calendar', { + Logger.debug('importing events into calendar', { url, data, }) @@ -202,6 +202,7 @@ export const importCalendarEvent = url => data => { components.forEach(componentName => { for (let componentId in file.split[componentName]) { const component = file.split[componentName][componentId] + Logger.info('importing event component', {component}) promises.push( Promise.resolve( Axios.put(url + getRandomString() + '.ics', component, { diff --git a/tests/Integration/KItinerary/ItineraryExtractorTest.php b/tests/Integration/KItinerary/ItineraryExtractorTest.php new file mode 100644 index 000000000..16aebe5fa --- /dev/null +++ b/tests/Integration/KItinerary/ItineraryExtractorTest.php @@ -0,0 +1,103 @@ +<?php declare(strict_types=1); + +/** + * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Tests\Unit\KItinerary; + +use ChristophWurst\KItinerary\Bin\BinaryAdapter; +use ChristophWurst\KItinerary\Flatpak\FlatpakAdapter; +use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Integration\KItinerary\ItineraryExtractor; +use OCP\ILogger; +use PHPUnit\Framework\MockObject\MockObject; + +class ItineraryExtractorTest extends TestCase { + + /** @var BinaryAdapter|MockObject */ + private $binaryAdapter; + + /** @var FlatpakAdapter|MockObject */ + private $flatpakAdapter; + + /** @var ILogger|MockObject */ + private $logger; + + /** @var ItineraryExtractor */ + private $extractor; + + protected function setUp(): void { + parent::setUp(); + + $this->binaryAdapter = $this->createMock(BinaryAdapter::class); + $this->flatpakAdapter = $this->createMock(FlatpakAdapter::class); + $this->logger = $this->createMock(ILogger::class); + + $this->extractor = new ItineraryExtractor( + $this->binaryAdapter, + $this->flatpakAdapter, + $this->logger + ); + } + + public function testNoAdapterAvailable() { + $this->binaryAdapter->expects($this->never()) + ->method('extractFromString'); + $this->flatpakAdapter->expects($this->never()) + ->method('extractFromString'); + + $itinerary = $this->extractor->extract(''); + + $this->assertEquals([], $itinerary->jsonSerialize()); + } + + public function testBinAvailable() { + $this->binaryAdapter->expects($this->once()) + ->method('isAvailable') + ->willReturn(true); + $this->binaryAdapter->expects($this->once()) + ->method('extractFromString') + ->with('data'); + $this->flatpakAdapter->expects($this->never()) + ->method('isAvailable'); + $this->flatpakAdapter->expects($this->never()) + ->method('extractFromString'); + + $itinerary = $this->extractor->extract('data'); + + $this->assertEquals([], $itinerary->jsonSerialize()); + } + + public function testFlatpakAvailable() { + $this->binaryAdapter->expects($this->never()) + ->method('extractFromString'); + $this->flatpakAdapter->expects($this->once()) + ->method('isAvailable') + ->willReturn(true); + $this->flatpakAdapter->expects($this->once()) + ->method('extractFromString'); + + $itinerary = $this->extractor->extract('data'); + + $this->assertEquals([], $itinerary->jsonSerialize()); + } + +} diff --git a/tests/Service/ItineraryServiceTest.php b/tests/Service/ItineraryServiceTest.php new file mode 100644 index 000000000..00041c46b --- /dev/null +++ b/tests/Service/ItineraryServiceTest.php @@ -0,0 +1,138 @@ +<?php declare(strict_types=1); + +/** + * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\Mail\Tests\Service; + +use ChristophWurst\KItinerary\Itinerary; +use ChristophWurst\Nextcloud\Testing\TestCase; +use Horde_Imap_Client_Socket; +use OCA\Mail\Account; +use OCA\Mail\IMAP\IMAPClientFactory; +use OCA\Mail\IMAP\MessageMapper; +use OCA\Mail\Integration\KItinerary\ItineraryExtractor; +use OCA\Mail\Service\ItineraryService; +use OCP\ICacheFactory; +use OCP\ILogger; +use PHPUnit\Framework\MockObject\MockObject; + +class ItineraryServiceTest extends TestCase { + + /** @var IMAPClientFactory|MockObject */ + private $imapClientFactory; + + /** @var MessageMapper|MockObject */ + private $messageMapper; + + /** @var ItineraryExtractor|MockObject */ + private $itineraryExtractor; + + /** @var ICacheFactory|MockObject */ + private $cacheFactor; + + /** @var ItineraryService */ + private $service; + + protected function setUp(): void { + parent::setUp(); + + $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->messageMapper = $this->createMock(MessageMapper::class); + $this->itineraryExtractor = $this->createMock(ItineraryExtractor::class); + $this->cacheFactor = $this->createMock(ICacheFactory::class); + + $this->service = new ItineraryService( + $this->imapClientFactory, + $this->messageMapper, + $this->itineraryExtractor, + $this->cacheFactor, + $this->createMock(ILogger::class) + ); + } + + public function testExtractNoBodyNoAttachments() { + /** @var Account|MockObject $account */ + $account = $this->createMock(Account::class); + $this->itineraryExtractor->expects($this->once()) + ->method('extract') + ->willReturn(new Itinerary()); + + $itinerary = $this->service->extract($account, 'INBOX', 13); + + $this->assertEquals([], $itinerary->jsonSerialize()); + } + + public function testExtractFromBody() { + /** @var Account|MockObject $account */ + $account = $this->createMock(Account::class); + $client = $this->createMock(Horde_Imap_Client_Socket::class); + $this->imapClientFactory->expects($this->once()) + ->method('getClient') + ->with($account) + ->willReturn($client); + $body = '<html><body>hello</body></html>'; + $this->messageMapper->expects($this->once()) + ->method('getHtmlBody') + ->with($client, 'INBOX', 13) + ->willReturn($body); + $this->itineraryExtractor->expects($this->at(0)) + ->method('extract') + ->with($body) + ->willReturn(new Itinerary(['datafrombody'])); + $this->itineraryExtractor->expects($this->at(1)) + ->method('extract') + ->with('["datafrombody"]') + ->willReturn(new Itinerary(['datafrombody'])); + + $itinerary = $this->service->extract($account, 'INBOX', 13); + + $this->assertEquals(['datafrombody'], $itinerary->jsonSerialize()); + } + + public function testExtractFromAttachments() { + /** @var Account|MockObject $account */ + $account = $this->createMock(Account::class); + $client = $this->createMock(Horde_Imap_Client_Socket::class); + $this->imapClientFactory->expects($this->once()) + ->method('getClient') + ->with($account) + ->willReturn($client); + $pdf = '%PDF-1.3.%'; + $this->messageMapper->expects($this->once()) + ->method('getRawAttachments') + ->with($client, 'INBOX', 13) + ->willReturn([$pdf]); + $this->itineraryExtractor->expects($this->at(0)) + ->method('extract') + ->with($pdf) + ->willReturn(new Itinerary(['datafrompdf'])); + $this->itineraryExtractor->expects($this->at(1)) + ->method('extract') + ->with('["datafrompdf"]') + ->willReturn(new Itinerary(['datafrompdf'])); + + $itinerary = $this->service->extract($account, 'INBOX', 13); + + $this->assertEquals(['datafrompdf'], $itinerary->jsonSerialize()); + } + +} diff --git a/tests/Unit/Controller/MessagesControllerTest.php b/tests/Unit/Controller/MessagesControllerTest.php index 75c4fc517..fee9d4c36 100644 --- a/tests/Unit/Controller/MessagesControllerTest.php +++ b/tests/Unit/Controller/MessagesControllerTest.php @@ -36,6 +36,7 @@ use OCA\Mail\Mailbox; use OCA\Mail\Model\IMAPMessage; use OCA\Mail\Model\Message; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\ItineraryService; use OCA\Mail\Service\MailManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -67,6 +68,9 @@ class MessagesControllerTest extends TestCase { /** @var MockObject|IMailSearch */ private $mailSearch; + /** @var ItineraryService|MockObject */ + private $itineraryService; + /** @var string */ private $userId; @@ -111,6 +115,7 @@ class MessagesControllerTest extends TestCase { $this->accountService = $this->createMock(AccountService::class); $this->mailManager = $this->createMock(IMailManager::class); $this->mailSearch = $this->createMock(IMailSearch::class); + $this->itineraryService = $this->createMock(ItineraryService::class); $this->userId = 'john'; $this->userFolder = $this->createMock(Folder::class); $this->request = $this->createMock(Request::class); @@ -134,6 +139,7 @@ class MessagesControllerTest extends TestCase { $this->accountService, $this->mailManager, $this->mailSearch, + $this->itineraryService, $this->userId, $this->userFolder, $this->logger, |