diff --git a/appinfo/info.xml b/appinfo/info.xml index cf8d707e2..0bf8183e6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -44,6 +44,7 @@ OCA\Deck\Command\UserExport + OCA\Deck\Command\BoardImport diff --git a/appinfo/routes.php b/appinfo/routes.php index 9ffa71655..2e615efab 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -90,6 +90,10 @@ ['name' => 'board_api#deleteAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl/{aclId}', 'verb' => 'DELETE'], ['name' => 'board_api#updateAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl/{aclId}', 'verb' => 'PUT'], + ['name' => 'board_import_api#getAllowedSystems', 'url' => '/api/v{apiVersion}/boards/import/getSystems','verb' => 'GET'], + ['name' => 'board_import_api#getConfigSchema', 'url' => '/api/v{apiVersion}/boards/import/config/schema/{name}','verb' => 'GET'], + ['name' => 'board_import_api#import', 'url' => '/api/v{apiVersion}/boards/import','verb' => 'POST'], + ['name' => 'stack_api#index', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks', 'verb' => 'GET'], ['name' => 'stack_api#getArchived', 'url' => '/api/v{apiVersion}/boards/{boardId}/stacks/archived', 'verb' => 'GET'], diff --git a/composer.json b/composer.json index 7bf463588..88d40fc46 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ } ], "require": { - "cogpowered/finediff": "0.3.*" + "cogpowered/finediff": "0.3.*", + "justinrainbow/json-schema": "^5.2" }, "require-dev": { "roave/security-advisories": "dev-master", diff --git a/composer.lock b/composer.lock index 08f9378c0..64256a599 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": "1f6d91406db4e7e16e31113951986e13", + "content-hash": "cf4fb1b424f5f0c36ecc1391b10de59c", "packages": [ { "name": "cogpowered/finediff", @@ -60,6 +60,76 @@ "source": "https://github.com/cogpowered/FineDiff/tree/master" }, "time": "2014-05-19T10:25:02+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.11", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ab6744b7296ded80f8cc4f9509abbff393399aa", + "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/justinrainbow/json-schema/issues", + "source": "https://github.com/justinrainbow/json-schema/tree/5.2.11" + }, + "time": "2021-07-22T09:24:00+00:00" } ], "packages-dev": [ @@ -3677,16 +3747,16 @@ }, { "name": "symfony/console", - "version": "v5.4.1", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9130e1a0fc93cb0faadca4ee917171bd2ca9e5f4" + "reference": "a2c6b7ced2eb7799a35375fb9022519282b5405e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9130e1a0fc93cb0faadca4ee917171bd2ca9e5f4", - "reference": "9130e1a0fc93cb0faadca4ee917171bd2ca9e5f4", + "url": "https://api.github.com/repos/symfony/console/zipball/a2c6b7ced2eb7799a35375fb9022519282b5405e", + "reference": "a2c6b7ced2eb7799a35375fb9022519282b5405e", "shasum": "" }, "require": { @@ -3756,7 +3826,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.1" + "source": "https://github.com/symfony/console/tree/v5.4.2" }, "funding": [ { @@ -3772,7 +3842,7 @@ "type": "tidelift" } ], - "time": "2021-12-09T11:22:43+00:00" + "time": "2021-12-20T16:11:12+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4070,16 +4140,16 @@ }, { "name": "symfony/finder", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d2f29dac98e96a98be467627bd49c2efb1bc2590" + "reference": "e77046c252be48c48a40816187ed527703c8f76c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d2f29dac98e96a98be467627bd49c2efb1bc2590", - "reference": "d2f29dac98e96a98be467627bd49c2efb1bc2590", + "url": "https://api.github.com/repos/symfony/finder/zipball/e77046c252be48c48a40816187ed527703c8f76c", + "reference": "e77046c252be48c48a40816187ed527703c8f76c", "shasum": "" }, "require": { @@ -4113,7 +4183,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.0" + "source": "https://github.com/symfony/finder/tree/v5.4.2" }, "funding": [ { @@ -4129,7 +4199,7 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2021-12-15T11:06:13+00:00" }, { "name": "symfony/options-resolver", @@ -4767,16 +4837,16 @@ }, { "name": "symfony/process", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "5be20b3830f726e019162b26223110c8f47cf274" + "reference": "2b3ba8722c4aaf3e88011be5e7f48710088fb5e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5be20b3830f726e019162b26223110c8f47cf274", - "reference": "5be20b3830f726e019162b26223110c8f47cf274", + "url": "https://api.github.com/repos/symfony/process/zipball/2b3ba8722c4aaf3e88011be5e7f48710088fb5e4", + "reference": "2b3ba8722c4aaf3e88011be5e7f48710088fb5e4", "shasum": "" }, "require": { @@ -4809,7 +4879,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.0" + "source": "https://github.com/symfony/process/tree/v5.4.2" }, "funding": [ { @@ -4825,7 +4895,7 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2021-12-27T21:01:00+00:00" }, { "name": "symfony/service-contracts", @@ -4974,16 +5044,16 @@ }, { "name": "symfony/string", - "version": "v5.4.0", + "version": "v5.4.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9ffaaba53c61ba75a3c7a3a779051d1e9ec4fd2d" + "reference": "e6a5d5ecf6589c5247d18e0e74e30b11dfd51a3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9ffaaba53c61ba75a3c7a3a779051d1e9ec4fd2d", - "reference": "9ffaaba53c61ba75a3c7a3a779051d1e9ec4fd2d", + "url": "https://api.github.com/repos/symfony/string/zipball/e6a5d5ecf6589c5247d18e0e74e30b11dfd51a3d", + "reference": "e6a5d5ecf6589c5247d18e0e74e30b11dfd51a3d", "shasum": "" }, "require": { @@ -5040,7 +5110,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.0" + "source": "https://github.com/symfony/string/tree/v5.4.2" }, "funding": [ { @@ -5056,7 +5126,7 @@ "type": "tidelift" } ], - "time": "2021-11-24T10:02:00+00:00" + "time": "2021-12-16T21:52:00+00:00" }, { "name": "theseer/tokenizer", diff --git a/docs/API.md b/docs/API.md index 00cb8f987..d3191b96a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -988,6 +988,49 @@ For now only `deck_file` is supported as an attachment type. ##### 200 Success +### GET /boards/import/getSystems - Import a board + +#### Request parameters + +| Parameter | Type | Description | +| ------------ | ------- | --------------------------------------------- | +| system | Integer | The system name. Example: trello | + +#### Response + +Make a request to see the json schema of system + +```json +{ +} +``` + +### GET /boards/import/config/system/{schema} - Import a board + +#### Request parameters + +#### Response + +```json +[ + "trello" +] +``` + +### POST /boards/import - Import a board + +#### Request parameters + +| Parameter | Type | Description | +| ------------ | ------- | --------------------------------------------- | +| system | string | The allowed name of system to import from | +| config | Object | The config object (JSON) | +| data | Object | The data object to import (JSON) | + +#### Response + +##### 200 Success + # OCS API The following endpoints are available through the Nextcloud OCS endpoint, which is available at `/ocs/v2.php/apps/deck/api/v1.0/`. diff --git a/docs/User_documentation_en.md b/docs/User_documentation_en.md index 25ea8f8ee..3a6f9dc18 100644 --- a/docs/User_documentation_en.md +++ b/docs/User_documentation_en.md @@ -14,6 +14,8 @@ Overall, Deck is easy to use. You can create boards, add users, share the Deck, 3. [Handle cards options](#3-handle-cards-options) 4. [Archive old tasks](#4-archive-old-tasks) 5. [Manage your board](#5-manage-your-board) +6. [Import boards](#6-import-boards) +7. [Search](#7-search) ### 1. Create my first board In this example, we're going to create a board and share it with an other nextcloud user. @@ -69,14 +71,80 @@ The **sharing tab** allows you to add users or even groups to your boards. **Deleted objects** allows you to return previously deleted stacks or cards. The **Timeline** allows you to see everything that happened in your boards. Everything! -## Search +### 6. Import boards + +Importing can be done using the API or the `occ` `deck:import` command. + +Comments with more than 1000 characters are placed as attached files to the card. + +It is possible to import from the following sources: + +#### Trello JSON + +Steps: +* Create the data file + * Access Trello + * go to the board you want to export + * Follow the steps in [Trello documentation](https://help.trello.com/article/747-exporting-data-from-trello-1) and export as JSON +* Create the configuration file +* Execute the import informing the import file path, data file and source as `Trello JSON` + +Create the configuration file respecting the [JSON Schema](https://github.com/nextcloud/deck/blob/master/lib/Service/fixtures/config-trelloJson-schema.json) for import `Trello JSON` + +Example configuration file: +```json +{ + "owner": "admin", + "color": "0800fd", + "uidRelation": { + "johndoe": "johndoe" + } +} +``` + +**Limitations**: + +Importing from a JSON file imports up to 1000 actions. To find out how many actions the board to be imported has, identify how many actions the JSON has. + +#### Trello API + +Import using API is recommended for boards with more than 1000 actions. + +Trello makes it possible to attach links to a card. Deck does not have this feature. Attachments and attachment links are added in a markdown table at the end of the description for every imported card that has attachments in Trello. + +* Get the API Key and API Token [here](https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/#authentication-and-authorization) +* Get the ID of the board you want to import by making a request to: +https://api.trello.com/1/members/me/boards?key={yourKey}&token={yourToken}&fields=id,name + + This ID you will use in the configuration file in the `board` property +* Create the configuration file + +Create the configuration file respecting the [JSON Schema](https://github.com/nextcloud/deck/blob/master/lib/Service/fixtures/config-trelloApi-schema.json) for import `Trello JSON` + +Example configuration file: +```json +{ + "owner": "admin", + "color": "0800fd", + "api": { + "key": "0cc175b9c0f1b6a831c399e269772661", + "token": "92eb5ffee6ae2fec3ad71c777531578f4a8a08f09d37b73795649038408b5f33" + }, + "board": "8277e0910d750195b4487976", + "uidRelation": { + "johndoe": "johndoe" + } +} +``` + +### 7. Search Deck provides a global search either through the unified search in the Nextcloud header or with the inline search next to the board controls. This search allows advanced filtering of cards across all board of the logged in user. For example the search `project tag:ToDo assigned:alice assigned:bob` will return all cards where the card title or description contains project **and** the tag ToDo is set **and** the user alice is assigned **and** the user bob is assigned. -### Supported search filters +#### Supported search filters | Filter | Operators | Query | | ----------- | ----------------- | ------------------------------------------------------------ | diff --git a/docs/implement-import.md b/docs/implement-import.md new file mode 100644 index 000000000..08f944e29 --- /dev/null +++ b/docs/implement-import.md @@ -0,0 +1,32 @@ +## Implement import + +* Create a new importer class extending `ABoardImportService` +* Create a listener for event `BoardImportGetAllowedEvent` to enable your importer. + > You can read more about listeners on [Nextcloud](https://docs.nextcloud.com/server/latest/developer_manual/basics/events.html?highlight=event#writing-a-listener) doc. + + Example: + +```php +class YourCustomImporterListener { + public function handle(Event $event): void { + if (!($event instanceof BoardImportGetAllowedEvent)) { + return; + } + + $event->getService()->addAllowedImportSystem([ + 'name' => YourCustomImporterService::$name, + 'class' => YourCustomImporterService::class, + 'internalName' => 'YourCustomImporter' + ]); + } +} +``` + * Register your listener on your `Application` class like this: +```php +$dispatcher = $this->getContainer()->query(IEventDispatcher::class); +$dispatcher->registerEventListener( + BoardImportGetAllowedEvent::class, + YourCustomImporterListener::class +); +``` +* Use the `lib/Service/Importer/Systems/TrelloJsonService.php` class as inspiration \ No newline at end of file diff --git a/docs/import-class-diagram.md b/docs/import-class-diagram.md new file mode 100644 index 000000000..cbd5b67ca --- /dev/null +++ b/docs/import-class-diagram.md @@ -0,0 +1,7 @@ +## Import class diagram + +Importing boards to the Deck implements the class diagram below. + +> **NOTE**: When making any changes to the structure of the classes or implementing import from other sources, edit the `BoardImport.yuml` file + +![Screenshot](resources/BoardImport.svg) \ No newline at end of file diff --git a/docs/resources/BoardImport.svg b/docs/resources/BoardImport.svg new file mode 100644 index 000000000..167f85f1a --- /dev/null +++ b/docs/resources/BoardImport.svg @@ -0,0 +1,214 @@ + + + + + + +G + + + +A0 + + + +Classes used on +board import. +Methods just to +illustrate. + + + +A1 + +ApiController + + + +A2 + +BoardImportApiController + ++import() ++getAllowedSystems() ++getConfigSchema() + + + +A1->A2 + + + + + +A3 + +BoardImportService + ++import() ++bootstrap() ++validateSystem() +#validateConfig() +#validateData() + + + +A2->A3 + + +uses + + + +A7 + +TrelloApiService + ++name:string + + + +A3->A7 + + +uses + + + +A9 + +TrelloJsonService + ++name:string +#needValidateData:true + + + +A3->A9 + + +uses + + + +A10 + + + +validateSystem is +public because is +used on Api. + + + +A3->A10 + + + + +A4 + +Command + + + +A5 + +BoardImport + ++boardImportCommandService + +#configure() +#execute(input,output) + + + +A4->A5 + + + + + +A6 + +BoardImportCommandService + ++bootstrap() ++import() ++validateSystem() +#validateConfig() +#validateData() + + + +A5->A6 + + +uses + + + +A6->A3 + + + + + +A7->A3 + + +uses + + + +A8 + +<<abstract>> +ABoardImportService + +#needValidateData:false + ++needValidateData():bool + + + +A7->A8 + + +implements + + + +A9->A3 + + +uses + + + +A9->A8 + + +implements + + + +A11 + + + +To create an import +to another system, +create another class +similar to this. + + + +A9->A11 + + + + diff --git a/docs/resources/BoardImport.yuml b/docs/resources/BoardImport.yuml new file mode 100644 index 000000000..4c681b79f --- /dev/null +++ b/docs/resources/BoardImport.yuml @@ -0,0 +1,24 @@ +// Created using [yUML](https://github.com/jaime-olivares/vscode-yuml) + +// {type:class} +// {direction:topDown} +// {generate:true} + +[note: Classes used on board import. Methods just to illustrate. {bg:cornsilk}] + +[ApiController]<-[BoardImportApiController|+import();+getAllowedSystems();+getConfigSchema()] +[BoardImportApiController]uses-.->[BoardImportService|+import();+bootstrap();+validateSystem();#validateConfig();#validateData();] + +[Command]<-[BoardImport|+boardImportCommandService|#configure();#execute(input,output)] +[BoardImport]uses-.->[BoardImportCommandService|+bootstrap();+import();+validateSystem();#validateConfig();#validateData()] +[BoardImportCommandService]->[BoardImportService] + +[BoardImportService]uses-.->[TrelloApiService|+name:string] +[TrelloApiService]uses-.->[BoardImportService] +[TrelloApiService]implements-.-^[<> ABoardImportService|#needValidateData:false|+needValidateData():bool] + +[BoardImportService]uses-.->[TrelloJsonService|+name:string;#needValidateData:true] +[TrelloJsonService]uses-.->[BoardImportService] +[BoardImportService]-[note: validateSystem is public because is used on Api. {bg:cornsilk}] +[TrelloJsonService]-[note: To create an import to another system, create another class similar to this. {bg:cornsilk}] +[TrelloJsonService]implements-.-^[<> ABoardImportService] diff --git a/lib/Command/BoardImport.php b/lib/Command/BoardImport.php new file mode 100644 index 000000000..15fec07b2 --- /dev/null +++ b/lib/Command/BoardImport.php @@ -0,0 +1,92 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Command; + +use OCA\Deck\Service\Importer\BoardImportCommandService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class BoardImport extends Command { + /** @var BoardImportCommandService */ + private $boardImportCommandService; + + public function __construct( + BoardImportCommandService $boardImportCommandService + ) { + $this->boardImportCommandService = $boardImportCommandService; + parent::__construct(); + } + + /** + * @return void + */ + protected function configure() { + $allowedSystems = $this->boardImportCommandService->getAllowedImportSystems(); + $names = array_column($allowedSystems, 'name'); + $this + ->setName('deck:import') + ->setDescription('Import data') + ->addOption( + 'system', + null, + InputOption::VALUE_REQUIRED, + 'Source system for import. Available options: ' . implode(', ', $names) . '.', + null + ) + ->addOption( + 'config', + null, + InputOption::VALUE_REQUIRED, + 'Configuration json file.', + 'config.json' + ) + ->addOption( + 'data', + null, + InputOption::VALUE_OPTIONAL, + 'Data file to import.', + 'data.json' + ) + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $this + ->boardImportCommandService + ->setInput($input) + ->setOutput($output) + ->setCommand($this) + ->import(); + $output->writeln('Done!'); + return 0; + } +} diff --git a/lib/Controller/BoardImportApiController.php b/lib/Controller/BoardImportApiController.php new file mode 100644 index 000000000..ec7e76cae --- /dev/null +++ b/lib/Controller/BoardImportApiController.php @@ -0,0 +1,85 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Controller; + +use OCA\Deck\Service\Importer\BoardImportService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; + +class BoardImportApiController extends OCSController { + /** @var BoardImportService */ + private $boardImportService; + /** @var string */ + private $userId; + + public function __construct( + string $appName, + IRequest $request, + BoardImportService $boardImportService, + string $userId + ) { + parent::__construct($appName, $request); + $this->boardImportService = $boardImportService; + $this->userId = $userId; + } + + /** + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + */ + public function import(string $system, array $config, array $data): DataResponse { + $this->boardImportService->setSystem($system); + $config = json_decode(json_encode($config)); + $config->owner = $this->userId; + $this->boardImportService->setConfigInstance($config); + $this->boardImportService->setData(json_decode(json_encode($data))); + $this->boardImportService->import(); + return new DataResponse($this->boardImportService->getBoard(), Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + */ + public function getAllowedSystems(): DataResponse { + $allowedSystems = $this->boardImportService->getAllowedImportSystems(); + return new DataResponse($allowedSystems, Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * @CORS + * @NoCSRFRequired + */ + public function getConfigSchema(string $name): DataResponse { + $this->boardImportService->setSystem($name); + $this->boardImportService->validateSystem(); + $jsonSchemaPath = json_decode(file_get_contents($this->boardImportService->getJsonSchemaPath())); + return new DataResponse($jsonSchemaPath, Http::STATUS_OK); + } +} diff --git a/lib/Event/ABoardImportGetAllowedEvent.php b/lib/Event/ABoardImportGetAllowedEvent.php new file mode 100644 index 000000000..9dc4287ee --- /dev/null +++ b/lib/Event/ABoardImportGetAllowedEvent.php @@ -0,0 +1,44 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Event; + +use OCA\Deck\Service\Importer\BoardImportService; +use OCP\EventDispatcher\Event; + +abstract class ABoardImportGetAllowedEvent extends Event { + private $service; + + public function __construct(BoardImportService $service) { + parent::__construct(); + + $this->service = $service; + } + + public function getService(): BoardImportService { + return $this->service; + } +} diff --git a/lib/Event/BoardImportGetAllowedEvent.php b/lib/Event/BoardImportGetAllowedEvent.php new file mode 100644 index 000000000..323bfc87e --- /dev/null +++ b/lib/Event/BoardImportGetAllowedEvent.php @@ -0,0 +1,29 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +declare(strict_types=1); + +namespace OCA\Deck\Event; + +class BoardImportGetAllowedEvent extends ABoardImportGetAllowedEvent { +} diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 2c5e359d3..7158c6d7c 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -86,7 +86,7 @@ private function getFileForAttachment(Attachment $attachment) { * @return ISimpleFolder * @throws NotPermittedException */ - private function getFolder(Attachment $attachment) { + public function getFolder(Attachment $attachment) { $folderName = 'file-card-' . (int)$attachment->getCardId(); try { $folder = $this->appData->getFolder($folderName); diff --git a/lib/Service/Importer/ABoardImportService.php b/lib/Service/Importer/ABoardImportService.php new file mode 100644 index 000000000..2e6acb605 --- /dev/null +++ b/lib/Service/Importer/ABoardImportService.php @@ -0,0 +1,136 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Service\Importer; + +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\Assignment; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\Stack; +use OCP\AppFramework\Db\Entity; +use OCP\Comments\IComment; + +abstract class ABoardImportService { + /** @var string */ + public static $name = ''; + /** @var BoardImportService */ + private $boardImportService; + /** @var bool */ + protected $needValidateData = true; + /** @var Stack[] */ + protected $stacks = []; + /** @var Label[] */ + protected $labels = []; + /** @var Card[] */ + protected $cards = []; + /** @var Acl[] */ + protected $acls = []; + /** @var IComment[][] */ + protected $comments = []; + /** @var Assignment[] */ + protected $assignments = []; + /** @var string[][] */ + protected $labelCardAssignments = []; + + /** + * Configure import service + * + * @return void + */ + abstract public function bootstrap(): void; + + abstract public function getBoard(): ?Board; + + /** + * @return Acl[] + */ + abstract public function getAclList(): array; + + /** + * @return Stack[] + */ + abstract public function getStacks(): array; + + /** + * @return Card[] + */ + abstract public function getCards(): array; + + abstract public function getCardAssignments(): array; + + abstract public function getCardLabelAssignment(): array; + + /** + * @return IComment[][]|array + */ + abstract public function getComments(): array; + + /** @return Label[] */ + abstract public function getLabels(): array; + + abstract public function validateUsers(): void; + + abstract public function getJsonSchemaPath(): string; + + public function updateStack(string $id, Stack $stack): void { + $this->stacks[$id] = $stack; + } + + public function updateCard(string $id, Card $card): void { + $this->cards[$id] = $card; + } + + public function updateLabel(string $code, Label $label): void { + $this->labels[$code] = $label; + } + + public function updateAcl(string $code, Acl $acl): void { + $this->acls[$code] = $acl; + } + + public function updateComment(string $cardId, string $commentId, IComment $comment): void { + $this->comments[$cardId][$commentId] = $comment; + } + + public function updateCardAssignment(string $cardId, string $assignmentId, Entity $assignment): void { + $this->assignments[$cardId][$assignmentId] = $assignment; + } + + public function updateCardLabelsAssignment(string $cardId, string $assignmentId, string $assignment): void { + $this->labelCardAssignments[$cardId][$assignmentId] = $assignment; + } + + public function setImportService(BoardImportService $service): void { + $this->boardImportService = $service; + } + + public function getImportService(): BoardImportService { + return $this->boardImportService; + } + + public function needValidateData(): bool { + return $this->needValidateData; + } +} diff --git a/lib/Service/Importer/BoardImportCommandService.php b/lib/Service/Importer/BoardImportCommandService.php new file mode 100644 index 000000000..d45c784de --- /dev/null +++ b/lib/Service/Importer/BoardImportCommandService.php @@ -0,0 +1,199 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Service\Importer; + +use OCA\Deck\Exceptions\ConflictException; +use OCA\Deck\NotFoundException; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\Question; + +class BoardImportCommandService extends BoardImportService { + /** + * @var Command + * @psalm-suppress PropertyNotSetInConstructor + */ + private $command; + /** + * @var InputInterface + * @psalm-suppress PropertyNotSetInConstructor + */ + private $input; + /** + * @var OutputInterface + * @psalm-suppress PropertyNotSetInConstructor + */ + private $output; + + public function setCommand(Command $command): self { + $this->command = $command; + return $this; + } + + public function getCommand(): Command { + return $this->command; + } + + public function setInput(InputInterface $input): self { + $this->input = $input; + return $this; + } + + public function getInput(): InputInterface { + return $this->input; + } + + public function setOutput(OutputInterface $output): self { + $this->output = $output; + return $this; + } + + public function getOutput(): OutputInterface { + return $this->output; + } + + protected function validateConfig(): void { + try { + $config = $this->getInput()->getOption('config'); + if (is_string($config)) { + if (!is_file($config)) { + throw new NotFoundException('It\'s not a valid config file.'); + } + $config = json_decode(file_get_contents($config)); + if (!$config instanceof \stdClass) { + throw new NotFoundException('Failed to parse JSON.'); + } + $this->setConfigInstance($config); + } + parent::validateConfig(); + return; + } catch (NotFoundException $e) { + $this->getOutput()->writeln('' . $e->getMessage() . ''); + $helper = $this->getCommand()->getHelper('question'); + $question = new Question( + "You can get more info on https://deck.readthedocs.io/en/latest/User_documentation_en/#6-import-boards\n" . + 'Please inform a valid config json file: ', + 'config.json' + ); + $question->setValidator(function (string $answer) { + if (!is_file($answer)) { + throw new \RuntimeException( + 'config file not found' + ); + } + return $answer; + }); + $configFile = $helper->ask($this->getInput(), $this->getOutput(), $question); + $this->getInput()->setOption('config', $configFile); + } catch (ConflictException $e) { + $this->getOutput()->writeln('Invalid config file'); + $this->getOutput()->writeln(array_map(function (array $v): string { + return $v['message']; + }, $e->getData())); + $this->getOutput()->writeln('Valid schema:'); + $this->getOutput()->writeln(print_r(file_get_contents($this->getJsonSchemaPath()), true)); + $this->getInput()->setOption('config', ''); + } + $this->validateConfig(); + } + + public function validateSystem(): void { + try { + parent::validateSystem(); + return; + } catch (\Throwable $th) { + } + $helper = $this->getCommand()->getHelper('question'); + $allowedSystems = $this->getAllowedImportSystems(); + $names = array_column($allowedSystems, 'name'); + $question = new ChoiceQuestion( + 'Please inform a source system', + $names, + 0 + ); + $question->setErrorMessage('System %s is invalid.'); + $selectedName = $helper->ask($this->getInput(), $this->getOutput(), $question); + $className = $allowedSystems[array_flip($names)[$selectedName]]['internalName']; + $this->setSystem($className); + return; + } + + protected function validateData(): void { + if (!$this->getImportSystem()->needValidateData()) { + return; + } + $data = $this->getInput()->getOption('data'); + if (is_string($data)) { + $data = json_decode(file_get_contents($data)); + if ($data instanceof \stdClass) { + $this->setData($data); + return; + } + } + $helper = $this->getCommand()->getHelper('question'); + $question = new Question( + 'Please provide a valid data json file: ', + 'data.json' + ); + $question->setValidator(function (string $answer) { + if (!is_file($answer)) { + throw new \RuntimeException( + 'Data file not found' + ); + } + return $answer; + }); + $data = $helper->ask($this->getInput(), $this->getOutput(), $question); + $this->getInput()->setOption('data', $data); + $this->validateData(); + } + + public function bootstrap(): void { + $this->setSystem($this->getInput()->getOption('system')); + parent::bootstrap(); + } + + public function import(): void { + $this->getOutput()->writeln('Starting import...'); + $this->bootstrap(); + $this->getOutput()->writeln('Importing board...'); + $this->importBoard(); + $this->getOutput()->writeln('Assign users to board...'); + $this->importAcl(); + $this->getOutput()->writeln('Importing labels...'); + $this->importLabels(); + $this->getOutput()->writeln('Importing stacks...'); + $this->importStacks(); + $this->getOutput()->writeln('Importing cards...'); + $this->importCards(); + $this->getOutput()->writeln('Assign cards to labels...'); + $this->assignCardsToLabels(); + $this->getOutput()->writeln('Importing comments...'); + $this->importComments(); + $this->getOutput()->writeln('Importing participants...'); + $this->importCardAssignments(); + } +} diff --git a/lib/Service/Importer/BoardImportService.php b/lib/Service/Importer/BoardImportService.php new file mode 100644 index 000000000..72b8b0c28 --- /dev/null +++ b/lib/Service/Importer/BoardImportService.php @@ -0,0 +1,449 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Service\Importer; + +use JsonSchema\Constraints\Constraint; +use JsonSchema\Validator; +use OCA\Deck\AppInfo\Application; +use OCA\Deck\BadRequestException; +use OCA\Deck\Db\AclMapper; +use OCA\Deck\Db\AssignmentMapper; +use OCA\Deck\Db\Attachment; +use OCA\Deck\Db\AttachmentMapper; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\LabelMapper; +use OCA\Deck\Db\StackMapper; +use OCA\Deck\Event\BoardImportGetAllowedEvent; +use OCA\Deck\Exceptions\ConflictException; +use OCA\Deck\NotFoundException; +use OCA\Deck\Service\FileService; +use OCA\Deck\Service\Importer\Systems\TrelloApiService; +use OCA\Deck\Service\Importer\Systems\TrelloJsonService; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Comments\NotFoundException as CommentNotFoundException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserManager; + +class BoardImportService { + /** @var IUserManager */ + private $userManager; + /** @var BoardMapper */ + private $boardMapper; + /** @var AclMapper */ + private $aclMapper; + /** @var LabelMapper */ + private $labelMapper; + /** @var StackMapper */ + private $stackMapper; + /** @var CardMapper */ + private $cardMapper; + /** @var AssignmentMapper */ + private $assignmentMapper; + /** @var AttachmentMapper */ + private $attachmentMapper; + /** @var ICommentsManager */ + private $commentsManager; + /** @var IEventDispatcher */ + private $eventDispatcher; + /** @var string */ + private $system = ''; + /** @var null|ABoardImportService */ + private $systemInstance; + /** @var array */ + private $allowedSystems = []; + /** + * Data object created from config JSON + * + * @var \stdClass + * @psalm-suppress PropertyNotSetInConstructor + */ + public $config; + /** + * Data object created from JSON of origin system + * + * @var \stdClass + * @psalm-suppress PropertyNotSetInConstructor + */ + private $data; + /** + * @var Board + */ + private $board; + + public function __construct( + IUserManager $userManager, + BoardMapper $boardMapper, + AclMapper $aclMapper, + LabelMapper $labelMapper, + StackMapper $stackMapper, + AssignmentMapper $assignmentMapper, + AttachmentMapper $attachmentMapper, + CardMapper $cardMapper, + ICommentsManager $commentsManager, + IEventDispatcher $eventDispatcher + ) { + $this->userManager = $userManager; + $this->boardMapper = $boardMapper; + $this->aclMapper = $aclMapper; + $this->labelMapper = $labelMapper; + $this->stackMapper = $stackMapper; + $this->cardMapper = $cardMapper; + $this->assignmentMapper = $assignmentMapper; + $this->attachmentMapper = $attachmentMapper; + $this->commentsManager = $commentsManager; + $this->eventDispatcher = $eventDispatcher; + $this->board = new Board(); + $this->disableCommentsEvents(); + } + + private function disableCommentsEvents(): void { + if (defined('PHPUNIT_RUN')) { + return; + } + $propertyEventHandlers = new \ReflectionProperty($this->commentsManager, 'eventHandlers'); + $propertyEventHandlers->setAccessible(true); + $propertyEventHandlers->setValue($this->commentsManager, []); + + $propertyEventHandlerClosures = new \ReflectionProperty($this->commentsManager, 'eventHandlerClosures'); + $propertyEventHandlerClosures->setAccessible(true); + $propertyEventHandlerClosures->setValue($this->commentsManager, []); + } + + public function import(): void { + $this->bootstrap(); + try { + $this->importBoard(); + $this->importAcl(); + $this->importLabels(); + $this->importStacks(); + $this->importCards(); + $this->assignCardsToLabels(); + $this->importComments(); + $this->importCardAssignments(); + } catch (\Throwable $th) { + throw new BadRequestException($th->getMessage()); + } + } + + public function validateSystem(): void { + $allowedSystems = $this->getAllowedImportSystems(); + $allowedSystems = array_column($allowedSystems, 'internalName'); + if (!in_array($this->getSystem(), $allowedSystems)) { + throw new NotFoundException('Invalid system'); + } + } + + /** + * @param mixed $system + * @return self + */ + public function setSystem($system): self { + $this->system = $system; + return $this; + } + + public function getSystem(): string { + return $this->system; + } + + public function addAllowedImportSystem($system): self { + $this->allowedSystems[] = $system; + return $this; + } + + public function getAllowedImportSystems(): array { + if (!$this->allowedSystems) { + $this->addAllowedImportSystem([ + 'name' => TrelloApiService::$name, + 'class' => TrelloApiService::class, + 'internalName' => 'TrelloApi' + ]); + $this->addAllowedImportSystem([ + 'name' => TrelloJsonService::$name, + 'class' => TrelloJsonService::class, + 'internalName' => 'TrelloJson' + ]); + } + $this->eventDispatcher->dispatchTyped(new BoardImportGetAllowedEvent($this)); + return $this->allowedSystems; + } + + public function getImportSystem(): ABoardImportService { + if (!$this->getSystem()) { + throw new NotFoundException('System to import not found'); + } + if (!is_object($this->systemInstance)) { + $systemClass = 'OCA\\Deck\\Service\\Importer\\Systems\\' . ucfirst($this->getSystem()) . 'Service'; + $this->systemInstance = \OC::$server->get($systemClass); + $this->systemInstance->setImportService($this); + } + return $this->systemInstance; + } + + public function setImportSystem(ABoardImportService $instance): void { + $this->systemInstance = $instance; + } + + public function importBoard(): void { + $board = $this->getImportSystem()->getBoard(); + if ($board) { + $this->boardMapper->insert($board); + $this->board = $board; + } + } + + public function getBoard(bool $reset = false): Board { + if ($reset) { + $this->board = new Board(); + } + return $this->board; + } + + public function importAcl(): void { + $aclList = $this->getImportSystem()->getAclList(); + foreach ($aclList as $code => $acl) { + $this->aclMapper->insert($acl); + $this->getImportSystem()->updateAcl($code, $acl); + } + $this->getBoard()->setAcl($aclList); + } + + public function importLabels(): void { + $labels = $this->getImportSystem()->getLabels(); + foreach ($labels as $code => $label) { + $this->labelMapper->insert($label); + $this->getImportSystem()->updateLabel($code, $label); + } + $this->getBoard()->setLabels($labels); + } + + public function importStacks(): void { + $stacks = $this->getImportSystem()->getStacks(); + foreach ($stacks as $code => $stack) { + $this->stackMapper->insert($stack); + $this->getImportSystem()->updateStack($code, $stack); + } + $this->getBoard()->setStacks(array_values($stacks)); + } + + public function importCards(): void { + $cards = $this->getImportSystem()->getCards(); + foreach ($cards as $code => $card) { + $createdAt = $card->getCreatedAt(); + $lastModified = $card->getLastModified(); + $this->cardMapper->insert($card); + $updateDate = false; + if ($createdAt && $createdAt !== $card->getCreatedAt()) { + $card->setCreatedAt($createdAt); + $updateDate = true; + } + if ($lastModified && $lastModified !== $card->getLastModified()) { + $card->setLastModified($lastModified); + $updateDate = true; + } + if ($updateDate) { + $this->cardMapper->update($card, false); + } + $this->getImportSystem()->updateCard($code, $card); + } + } + + /** + * @param mixed $cardId + * @param mixed $labelId + * @return self + */ + public function assignCardToLabel($cardId, $labelId): self { + $this->cardMapper->assignLabel( + $cardId, + $labelId + ); + return $this; + } + + public function assignCardsToLabels(): void { + $data = $this->getImportSystem()->getCardLabelAssignment(); + foreach ($data as $cardId => $assignemnt) { + foreach ($assignemnt as $assignmentId => $labelId) { + $this->assignCardToLabel( + $cardId, + $labelId + ); + $this->getImportSystem()->updateCardLabelsAssignment($cardId, $assignmentId, $labelId); + } + } + } + + public function importComments(): void { + $allComments = $this->getImportSystem()->getComments(); + foreach ($allComments as $cardId => $comments) { + foreach ($comments as $commentId => $comment) { + $this->insertComment($cardId, $comment); + $this->getImportSystem()->updateComment($cardId, $commentId, $comment); + } + } + } + + private function insertComment(string $cardId, IComment $comment): void { + $comment->setObject('deckCard', $cardId); + $comment->setVerb('comment'); + // Check if parent is a comment on the same card + if ($comment->getParentId() !== '0') { + try { + $parent = $this->commentsManager->get($comment->getParentId()); + if ($parent->getObjectType() !== Application::COMMENT_ENTITY_TYPE || $parent->getObjectId() !== $cardId) { + throw new CommentNotFoundException(); + } + } catch (CommentNotFoundException $e) { + throw new BadRequestException('Invalid parent id: The parent comment was not found or belongs to a different card'); + } + } + + try { + $this->commentsManager->save($comment); + } catch (\InvalidArgumentException $e) { + throw new BadRequestException('Invalid input values'); + } catch (CommentNotFoundException $e) { + throw new NotFoundException('Could not create comment.'); + } + } + + public function importCardAssignments(): void { + $allAssignments = $this->getImportSystem()->getCardAssignments(); + foreach ($allAssignments as $cardId => $assignments) { + foreach ($assignments as $assignmentId => $assignment) { + $this->assignmentMapper->insert($assignment); + $this->getImportSystem()->updateCardAssignment($cardId, $assignmentId, $assignment); + } + } + } + + public function insertAttachment(Attachment $attachment, string $content): Attachment { + $service = \OC::$server->get(FileService::class); + $folder = $service->getFolder($attachment); + + if ($folder->fileExists($attachment->getData())) { + $attachment = $this->attachmentMapper->findByData($attachment->getCardId(), $attachment->getData()); + throw new ConflictException('File already exists.', $attachment); + } + + $target = $folder->newFile($attachment->getData()); + $target->putContent($content); + + $attachment = $this->attachmentMapper->insert($attachment); + + $service->extendData($attachment); + return $attachment; + } + + public function setData(\stdClass $data): void { + $this->data = $data; + } + + public function getData(): \stdClass { + return $this->data; + } + + /** + * Define a config + * + * @param string $configName + * @param mixed $value + * @return void + */ + public function setConfig(string $configName, $value): void { + if (empty((array) $this->config)) { + $this->setConfigInstance(new \stdClass); + } + $this->config->$configName = $value; + } + + /** + * Get a config + * + * @param string $configName config name + * @return mixed + */ + public function getConfig(string $configName) { + if (!property_exists($this->config, $configName)) { + return; + } + return $this->config->$configName; + } + + /** + * @param \stdClass $config + * @return self + */ + public function setConfigInstance($config): self { + $this->config = $config; + return $this; + } + + public function getConfigInstance(): \stdClass { + return $this->config; + } + + protected function validateConfig(): void { + $config = $this->getConfigInstance(); + $schemaPath = $this->getJsonSchemaPath(); + $validator = new Validator(); + $newConfig = clone $config; + $validator->validate( + $newConfig, + (object)['$ref' => 'file://' . realpath($schemaPath)], + Constraint::CHECK_MODE_APPLY_DEFAULTS + ); + if (!$validator->isValid()) { + throw new ConflictException('Invalid config file', $validator->getErrors()); + } + $this->setConfigInstance($newConfig); + $this->validateOwner(); + } + + public function getJsonSchemaPath(): string { + return $this->getImportSystem()->getJsonSchemaPath(); + } + + public function validateOwner(): void { + $owner = $this->userManager->get($this->getConfig('owner')); + if (!$owner) { + throw new \LogicException('Owner "' . $this->getConfig('owner')->getUID() . '" not found on Nextcloud. Check setting json.'); + } + $this->setConfig('owner', $owner); + } + + protected function validateData(): void { + } + + public function bootstrap(): void { + $this->validateSystem(); + $this->validateConfig(); + $this->validateData(); + $this->getImportSystem()->bootstrap(); + } +} diff --git a/lib/Service/Importer/Systems/TrelloApiService.php b/lib/Service/Importer/Systems/TrelloApiService.php new file mode 100644 index 000000000..d2466ae1b --- /dev/null +++ b/lib/Service/Importer/Systems/TrelloApiService.php @@ -0,0 +1,214 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Service\Importer\Systems; + +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class TrelloApiService extends TrelloJsonService { + /** @var string */ + public static $name = 'Trello API'; + protected $needValidateData = false; + /** @var IClient */ + private $httpClient; + /** @var LoggerInterface */ + protected $logger; + /** @var string */ + private $baseApiUrl = 'https://api.trello.com/1'; + /** @var ?\stdClass[] */ + private $boards; + + public function __construct( + IUserManager $userManager, + IURLGenerator $urlGenerator, + IL10N $l10n, + LoggerInterface $logger, + IClientService $httpClientService + ) { + parent::__construct($userManager, $urlGenerator, $l10n); + $this->logger = $logger; + $this->httpClient = $httpClientService->newClient(); + } + + public function bootstrap(): void { + $this->populateBoard(); + $this->populateMembers(); + $this->populateLabels(); + $this->populateLists(); + $this->populateCheckLists(); + $this->populateCards(); + $this->populateActions(); + parent::bootstrap(); + } + + public function getJsonSchemaPath(): string { + return implode(DIRECTORY_SEPARATOR, [ + __DIR__, + '..', + 'fixtures', + 'config-trelloApi-schema.json', + ]); + } + + private function populateActions(): void { + $data = $this->getImportService()->getData(); + $data->actions = $this->doRequest( + '/boards/' . $data->id . '/actions', + [ + 'filter' => 'commentCard,createCard', + 'fields=memberCreator,type,data,date', + 'memberCreator_fields' => 'username', + 'limit' => 1000 + ] + ); + } + + private function populateCards(): void { + $data = $this->getImportService()->getData(); + $data->cards = $this->doRequest( + '/boards/' . $data->id . '/cards', + [ + 'fields' => 'id,idMembers,dateLastActivity,closed,idChecklists,name,idList,pos,desc,due,labels', + 'attachments' => true, + 'attachment_fields' => 'name,url,date', + 'limit' => 1000 + ] + ); + } + + private function populateCheckLists(): void { + $data = $this->getImportService()->getData(); + $data->checklists = $this->doRequest( + '/boards/' . $data->id . '/checkLists', + [ + 'fields' => 'id,idCard,name', + 'checkItem_fields' => 'id,state,name', + 'limit' => 1000 + ] + ); + } + + private function populateLists(): void { + $data = $this->getImportService()->getData(); + $data->lists = $this->doRequest( + '/boards/' . $data->id . '/lists', + [ + 'fields' => 'id,name,closed', + 'limit' => 1000 + ] + ); + } + + private function populateLabels(): void { + $data = $this->getImportService()->getData(); + $data->labels = $this->doRequest( + '/boards/' . $data->id . '/labels', + [ + 'fields' => 'id,color,name', + 'limit' => 1000 + ] + ); + } + + private function populateMembers(): void { + $data = $this->getImportService()->getData(); + $data->members = $this->doRequest( + '/boards/' . $data->id . '/members', + [ + 'fields' => 'username', + 'limit' => 1000 + ] + ); + } + + private function populateBoard(): void { + $toImport = $this->getImportService()->getConfig('board'); + $board = $this->doRequest( + '/boards/' . $toImport, + ['fields' => 'id,name'] + ); + if ($board instanceof \stdClass) { + $this->getImportService()->setData($board); + return; + } + throw new \Exception('Invalid board id to import'); + } + + /** + * @return array|\stdClass + */ + private function doRequest(string $path = '', array $queryString = []) { + $target = $this->baseApiUrl . $path; + try { + $result = $this->httpClient + ->get($target, $this->getQueryString($queryString)) + ->getBody(); + if (is_string($result)) { + $data = json_decode($result); + if (is_array($data)) { + $data = array_merge( + $data, + $this->paginate($path, $queryString, $data) + ); + } + return $data; + } + throw new \Exception('Invalid return of api'); + } catch (\Throwable $e) { + $this->logger->critical( + $e->getMessage(), + ['app' => 'deck'] + ); + throw new \Exception($e->getMessage()); + } + } + + private function paginate(string $path = '', array $queryString = [], array $data = []): array { + if (empty($queryString['limit'])) { + return []; + } + if (count($data) < $queryString['limit']) { + return []; + } + $queryString['before'] = end($data)->id; + $return = $this->doRequest($path, $queryString); + if (is_array($return)) { + return $return; + } + throw new \Exception('Invalid return of api'); + } + + private function getQueryString(array $params = []): array { + $apiSettings = $this->getImportService()->getConfig('api'); + $params['key'] = $apiSettings->key; + $params['token'] = $apiSettings->token; + return [ + 'query' => $params + ]; + } +} diff --git a/lib/Service/Importer/Systems/TrelloJsonService.php b/lib/Service/Importer/Systems/TrelloJsonService.php new file mode 100644 index 000000000..6aeebd0b0 --- /dev/null +++ b/lib/Service/Importer/Systems/TrelloJsonService.php @@ -0,0 +1,400 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Service\Importer\Systems; + +use OC\Comments\Comment; +use OCA\Deck\BadRequestException; +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\Assignment; +use OCA\Deck\Db\Attachment; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\Stack; +use OCA\Deck\Service\Importer\ABoardImportService; +use OCP\Comments\IComment; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; + +class TrelloJsonService extends ABoardImportService { + /** @var string */ + public static $name = 'Trello JSON'; + /** @var IUserManager */ + private $userManager; + /** @var IURLGenerator */ + private $urlGenerator; + /** @var IL10N */ + private $l10n; + /** @var IUser[] */ + private $members = []; + + public function __construct( + IUserManager $userManager, + IURLGenerator $urlGenerator, + IL10N $l10n + ) { + $this->userManager = $userManager; + $this->urlGenerator = $urlGenerator; + $this->l10n = $l10n; + } + + public function bootstrap(): void { + $this->validateUsers(); + } + + public function getJsonSchemaPath(): string { + return implode(DIRECTORY_SEPARATOR, [ + __DIR__, + '..', + 'fixtures', + 'config-trelloJson-schema.json', + ]); + } + + public function validateUsers(): void { + if (empty($this->getImportService()->getConfig('uidRelation'))) { + return; + } + foreach ($this->getImportService()->getConfig('uidRelation') as $trelloUid => $nextcloudUid) { + $user = array_filter($this->getImportService()->getData()->members, function (\stdClass $u) use ($trelloUid) { + return $u->username === $trelloUid; + }); + if (!$user) { + throw new \LogicException('Trello user ' . $trelloUid . ' not found in property "members" of json data'); + } + if (!is_string($nextcloudUid) && !is_numeric($nextcloudUid)) { + throw new \LogicException('User on setting uidRelation is invalid'); + } + $nextcloudUid = (string) $nextcloudUid; + $this->getImportService()->getConfig('uidRelation')->$trelloUid = $this->userManager->get($nextcloudUid); + if (!$this->getImportService()->getConfig('uidRelation')->$trelloUid) { + throw new \LogicException('User on setting uidRelation not found: ' . $nextcloudUid); + } + $user = current($user); + $this->members[$user->id] = $this->getImportService()->getConfig('uidRelation')->$trelloUid; + } + } + + public function getCardAssignments(): array { + $assignments = []; + foreach ($this->getImportService()->getData()->cards as $trelloCard) { + foreach ($trelloCard->idMembers as $idMember) { + if (empty($this->members[$idMember])) { + continue; + } + $assignment = new Assignment(); + $assignment->setCardId($this->cards[$trelloCard->id]->getId()); + $assignment->setParticipant($this->members[$idMember]->getUID()); + $assignment->setType(Assignment::TYPE_USER); + $assignments[$trelloCard->id][] = $assignment; + } + } + return $assignments; + } + + public function getComments(): array { + $comments = []; + foreach ($this->getImportService()->getData()->cards as $trelloCard) { + $values = array_filter( + $this->getImportService()->getData()->actions, + function (\stdClass $a) use ($trelloCard) { + return $a->type === 'commentCard' && $a->data->card->id === $trelloCard->id; + } + ); + $keys = array_map(function (\stdClass $c): string { + return $c->id; + }, $values); + $trelloComments = array_combine($keys, $values); + $trelloComments = $this->sortComments($trelloComments); + foreach ($trelloComments as $commentId => $trelloComment) { + $cardId = $this->cards[$trelloCard->id]->getId(); + $comment = new Comment(); + if (!empty($this->getImportService()->getConfig('uidRelation')->{$trelloComment->memberCreator->username})) { + $actor = $this->getImportService()->getConfig('uidRelation')->{$trelloComment->memberCreator->username}->getUID(); + } else { + $actor = $this->getImportService()->getConfig('owner')->getUID(); + } + $message = $this->replaceUsernames($trelloComment->data->text); + if (mb_strlen($message, 'UTF-8') > IComment::MAX_MESSAGE_LENGTH) { + $attachment = new Attachment(); + $attachment->setCardId($cardId); + $attachment->setType('deck_file'); + $attachment->setCreatedBy($actor); + $attachment->setLastModified(time()); + $attachment->setCreatedAt(time()); + $attachment->setData('comment_' . $commentId . '.md'); + $attachment = $this->getImportService()->insertAttachment($attachment, $message); + + $urlToDownloadAttachment = $this->urlGenerator->linkToRouteAbsolute( + 'deck.attachment.display', + [ + 'cardId' => $cardId, + 'attachmentId' => $attachment->getId() + ] + ); + $message = $this->l10n->t( + "This comment has more than %s characters.\n" . + "Added as an attachment to the card with name %s\n" . + "Accessible on URL: %s.", + [ + IComment::MAX_MESSAGE_LENGTH, + 'comment_' . $commentId . '.md', + $urlToDownloadAttachment + ] + ); + } + $comment + ->setActor('users', $actor) + ->setMessage($message) + ->setCreationDateTime( + \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $trelloComment->date) + ); + $comments[$cardId][$commentId] = $comment; + } + } + return $comments; + } + + private function sortComments(array $comments): array { + $comparison = function (\stdClass $a, \stdClass $b): int { + if ($a->date == $b->date) { + return 0; + } + return ($a->date < $b->date) ? -1 : 1; + }; + + usort($comments, $comparison); + return $comments; + } + + public function getCardLabelAssignment(): array { + $cardsLabels = []; + foreach ($this->getImportService()->getData()->cards as $trelloCard) { + foreach ($trelloCard->labels as $label) { + $cardId = $this->cards[$trelloCard->id]->getId(); + $labelId = $this->labels[$label->id]->getId(); + $cardsLabels[$cardId][] = $labelId; + } + } + return $cardsLabels; + } + + public function getBoard(): Board { + $board = $this->getImportService()->getBoard(); + if (empty($this->getImportService()->getData()->name)) { + throw new BadRequestException('Invalid name of board'); + } + $board->setTitle($this->getImportService()->getData()->name); + $board->setOwner($this->getImportService()->getConfig('owner')->getUID()); + $board->setColor($this->getImportService()->getConfig('color')); + return $board; + } + + /** + * @return Label[] + */ + public function getLabels(): array { + foreach ($this->getImportService()->getData()->labels as $trelloLabel) { + $label = new Label(); + if (empty($trelloLabel->name)) { + $label->setTitle('Unnamed ' . $trelloLabel->color . ' label'); + } else { + $label->setTitle($trelloLabel->name); + } + $label->setColor($this->translateColor($trelloLabel->color)); + $label->setBoardId($this->getImportService()->getBoard()->getId()); + $this->labels[$trelloLabel->id] = $label; + } + return $this->labels; + } + + /** + * @return Stack[] + */ + public function getStacks(): array { + $return = []; + foreach ($this->getImportService()->getData()->lists as $order => $list) { + $stack = new Stack(); + if ($list->closed) { + $stack->setDeletedAt(time()); + } + $stack->setTitle($list->name); + $stack->setBoardId($this->getImportService()->getBoard()->getId()); + $stack->setOrder($order + 1); + $return[$list->id] = $stack; + } + return $return; + } + + /** + * @return Card[] + */ + public function getCards(): array { + $checklists = []; + foreach ($this->getImportService()->getData()->checklists as $checklist) { + $checklists[$checklist->idCard][$checklist->id] = $this->formulateChecklistText($checklist); + } + $this->getImportService()->getData()->checklists = $checklists; + + $cards = []; + foreach ($this->getImportService()->getData()->cards as $trelloCard) { + $card = new Card(); + $lastModified = \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $trelloCard->dateLastActivity); + $card->setLastModified($lastModified->format('Y-m-d H:i:s')); + if ($trelloCard->closed) { + $card->setArchived(true); + } + if ((count($trelloCard->idChecklists) !== 0)) { + foreach ($this->getImportService()->getData()->checklists[$trelloCard->id] as $checklist) { + $trelloCard->desc .= "\n" . $checklist; + } + } + $this->appendAttachmentsToDescription($trelloCard); + + $card->setTitle($trelloCard->name); + $card->setStackId($this->stacks[$trelloCard->idList]->getId()); + $cardsOnStack = $this->stacks[$trelloCard->idList]->getCards(); + $cardsOnStack[] = $card; + $this->stacks[$trelloCard->idList]->setCards($cardsOnStack); + $card->setType('plain'); + $card->setOrder($trelloCard->pos); + $card->setOwner($this->getImportService()->getConfig('owner')->getUID()); + + $lastModified = \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $trelloCard->dateLastActivity); + $card->setLastModified($lastModified->format('U')); + + $createCardDate = array_filter( + $this->getImportService()->getData()->actions, + function (\stdClass $a) use ($trelloCard) { + return $a->type === 'createCard' && $a->data->card->id === $trelloCard->id; + } + ); + $createCardDate = current($createCardDate); + $createCardDate = \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $createCardDate->date); + if ($createCardDate) { + $card->setCreatedAt($createCardDate->format('U')); + } else { + $card->setCreatedAt($lastModified->format('U')); + } + + $card->setDescription($trelloCard->desc); + if ($trelloCard->due) { + $duedate = \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $trelloCard->due) + ->format('Y-m-d H:i:s'); + $card->setDuedate($duedate); + } + $cards[$trelloCard->id] = $card; + } + return $cards; + } + + /** + * @return Acl[] + */ + public function getAclList(): array { + $return = []; + foreach ($this->members as $member) { + if ($member->getUID() === $this->getImportService()->getConfig('owner')->getUID()) { + continue; + } + $acl = new Acl(); + $acl->setBoardId($this->getImportService()->getBoard()->getId()); + $acl->setType(Acl::PERMISSION_TYPE_USER); + $acl->setParticipant($member->getUID()); + $acl->setPermissionEdit(false); + $acl->setPermissionShare(false); + $acl->setPermissionManage(false); + $return[] = $acl; + } + return $return; + } + + private function translateColor(string $color): string { + switch ($color) { + case 'red': + return 'ff0000'; + case 'yellow': + return 'ffff00'; + case 'orange': + return 'ff6600'; + case 'green': + return '00ff00'; + case 'purple': + return '9900ff'; + case 'blue': + return '0000ff'; + case 'sky': + return '00ccff'; + case 'lime': + return '00ff99'; + case 'pink': + return 'ff66cc'; + case 'black': + return '000000'; + default: + return 'ffffff'; + } + } + + private function replaceUsernames(string $text): string { + foreach ($this->getImportService()->getConfig('uidRelation') as $trello => $nextcloud) { + $text = str_replace($trello, $nextcloud->getUID(), $text); + } + return $text; + } + + private function checklistItem(\stdClass $item): string { + if (($item->state == 'incomplete')) { + $string_start = '- [ ]'; + } else { + $string_start = '- [x]'; + } + $check_item_string = $string_start . ' ' . $item->name . "\n"; + return $check_item_string; + } + + private function formulateChecklistText(\stdClass $checklist): string { + $checklist_string = "\n\n## {$checklist->name}\n"; + foreach ($checklist->checkItems as $item) { + $checklist_item_string = $this->checklistItem($item); + $checklist_string = $checklist_string . "\n" . $checklist_item_string; + } + return $checklist_string; + } + + private function appendAttachmentsToDescription(\stdClass $trelloCard): void { + if (empty($trelloCard->attachments)) { + return; + } + $trelloCard->desc .= "\n\n## {$this->l10n->t('Attachments')}\n"; + $trelloCard->desc .= "| {$this->l10n->t('File')} | {$this->l10n->t('date')} |\n"; + $trelloCard->desc .= "|---|---\n"; + foreach ($trelloCard->attachments as $attachment) { + $name = mb_strlen($attachment->name, 'UTF-8') ? $attachment->name : $attachment->url; + $trelloCard->desc .= "| [{$name}]({$attachment->url}) | {$attachment->date} |\n"; + } + } +} diff --git a/lib/Service/Importer/fixtures/config-trelloApi-schema.json b/lib/Service/Importer/fixtures/config-trelloApi-schema.json new file mode 100644 index 000000000..056c9e519 --- /dev/null +++ b/lib/Service/Importer/fixtures/config-trelloApi-schema.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties": { + "api": { + "type": "object", + "properties": { + "key": { + "type": "string", + "pattern": "^[0-9a-fA-F]{32}$" + }, + "token": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + } + } + }, + "board": { + "type": "string", + "pattern": "^\\w{1,}$" + }, + "uidRelation": { + "type": "object", + "comment": "Relationship between Trello and Nextcloud usernames", + "example": { + "johndoe": "admin" + } + }, + "owner": { + "type": "string", + "required": true, + "comment": "Nextcloud owner username" + }, + "color": { + "type": "string", + "required": true, + "pattern": "^[0-9a-fA-F]{6}$", + "comment": "Default color for the board. If you don't inform, the default color will be used.", + "default": "0800fd" + } + } +} \ No newline at end of file diff --git a/lib/Service/Importer/fixtures/config-trelloJson-schema.json b/lib/Service/Importer/fixtures/config-trelloJson-schema.json new file mode 100644 index 000000000..7635727c1 --- /dev/null +++ b/lib/Service/Importer/fixtures/config-trelloJson-schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "uidRelation": { + "type": "object", + "comment": "Relationship between Trello and Nextcloud usernames", + "example": { + "johndoe": "admin" + } + }, + "owner": { + "type": "string", + "required": true, + "comment": "Nextcloud owner username" + }, + "color": { + "type": "string", + "required": true, + "pattern": "^[0-9a-fA-F]{6}$", + "comment": "Default color for the board. If you don't inform, the default color will be used.", + "default": "0800fd" + } + } +} \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index c80c417be..3e8defc2e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,4 +11,6 @@ pages: - Nextcloud API: API-Nextcloud.md - Developer documentation: - Data structure: structure.md - + - Import documentation: + - Implement import: implement-import.md + - Class diagram: import-class-diagram.md diff --git a/tests/data/config-trelloJson.json b/tests/data/config-trelloJson.json new file mode 100644 index 000000000..48a27f54e --- /dev/null +++ b/tests/data/config-trelloJson.json @@ -0,0 +1,7 @@ +{ + "owner": "admin", + "color": "0800fd", + "uidRelation": { + "johndoe": "johndoe" + } +} \ No newline at end of file diff --git a/tests/data/data-trelloJson.json b/tests/data/data-trelloJson.json new file mode 100644 index 000000000..5d27a8c54 --- /dev/null +++ b/tests/data/data-trelloJson.json @@ -0,0 +1,582 @@ +{ + "id": "fakeboardidhash", + "name": "Test Board Name", + "desc": "", + "descData": null, + "closed": false, + "dateClosed": null, + "idOrganization": null, + "shortLink": "qwerty", + "powerUps": [], + "dateLastActivity": "2021-07-10T17:01:58.633Z", + "idTags": [], + "datePluginDisable": null, + "creationMethod": null, + "idBoardSource": null, + "idMemberCreator": "fakeidmemberhash", + "idEnterprise": null, + "pinned": false, + "starred": false, + "url": "https://trello.com/b/qwerty/fakeboardurl", + "prefs": { + "permissionLevel": "private", + "hideVotes": false, + "voting": "disabled", + "comments": "members", + "invitations": "members", + "selfJoin": false, + "cardCovers": true, + "isTemplate": false, + "cardAging": "regular", + "calendarFeedEnabled": false, + "background": "blue", + "backgroundImage": null, + "backgroundImageScaled": null, + "backgroundTile": false, + "backgroundBrightness": "dark", + "backgroundColor": "#0079BF", + "backgroundBottomColor": "#0079BF", + "backgroundTopColor": "#0079BF", + "canBePublic": true, + "canBeEnterprise": true, + "canBeOrg": true, + "canBePrivate": true, + "canInvite": true + }, + "shortUrl": "https://trello.com/b/qwerty", + "premiumFeatures": [], + "enterpriseOwned": false, + "ixUpdate": "67", + "limits": { + "attachments": { + "perBoard": { + "status": "ok", + "disableAt": 36000, + "warnAt": 32400 + }, + "perCard": { + "status": "ok", + "disableAt": 1000, + "warnAt": 900 + } + }, + "boards": { + "totalMembersPerBoard": { + "status": "ok", + "disableAt": 1600, + "warnAt": 1440 + } + }, + "cards": { + "openPerBoard": { + "status": "ok", + "disableAt": 5000, + "warnAt": 4500 + }, + "openPerList": { + "status": "ok", + "disableAt": 5000, + "warnAt": 4500 + }, + "totalPerBoard": { + "status": "ok", + "disableAt": 2000000, + "warnAt": 1800000 + }, + "totalPerList": { + "status": "ok", + "disableAt": 1000000, + "warnAt": 900000 + } + }, + "checklists": { + "perBoard": { + "status": "ok", + "disableAt": 2000000, + "warnAt": 1800000 + }, + "perCard": { + "status": "ok", + "disableAt": 500, + "warnAt": 450 + } + }, + "checkItems": { + "perChecklist": { + "status": "ok", + "disableAt": 200, + "warnAt": 180 + } + }, + "customFields": { + "perBoard": { + "status": "ok", + "disableAt": 50, + "warnAt": 45 + } + }, + "customFieldOptions": { + "perField": { + "status": "ok", + "disableAt": 50, + "warnAt": 45 + } + }, + "labels": { + "perBoard": { + "status": "ok", + "disableAt": 1000, + "warnAt": 900 + } + }, + "lists": { + "openPerBoard": { + "status": "ok", + "disableAt": 500, + "warnAt": 450 + }, + "totalPerBoard": { + "status": "ok", + "disableAt": 3000, + "warnAt": 2700 + } + }, + "stickers": { + "perCard": { + "status": "ok", + "disableAt": 70, + "warnAt": 63 + } + }, + "reactions": { + "perAction": { + "status": "ok", + "disableAt": 1000, + "warnAt": 900 + }, + "uniquePerAction": { + "status": "ok", + "disableAt": 17, + "warnAt": 16 + } + } + }, + "subscribed": false, + "templateGallery": null, + "dateLastView": "2021-07-10T17:01:58.665Z", + "labelNames": { + "green": "", + "yellow": "", + "orange": "", + "red": "", + "purple": "", + "blue": "", + "sky": "", + "lime": "", + "pink": "", + "black": "" + }, + "actions": [ + { + "id": "60e9d2869efe2e1141be2798", + "idMemberCreator": "fakeidmemberhash", + "data": { + "idMember": "fakeidmemberhash", + "deactivated": false, + "card": { + "id": "hashcard7", + "name": "Name Card 7", + "idShort": 7, + "shortLink": "fakeshortlinkcard7" + }, + "board": { + "id": "fakeboardidhash", + "name": "Test Board Name", + "shortLink": "qwerty" + }, + "member": { + "id": "fakeidmemberhash", + "name": "John Doe" + } + }, + "type": "removeMemberFromCard", + "date": "2021-07-10T17:01:58.636Z", + "appCreator": null, + "limits": {}, + "member": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + }, + "memberCreator": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + } + }, + { + "id": "60e9d1832ff82d10c0cea6ba", + "idMemberCreator": "fakeidmemberhash", + "data": { + "idMember": "fakeidmemberhash", + "card": { + "id": "hashcard7", + "name": "Name Card 7", + "idShort": 7, + "shortLink": "fakeshortlinkcard7" + }, + "board": { + "id": "fakeboardidhash", + "name": "Test Board Name", + "shortLink": "qwerty" + }, + "member": { + "id": "fakeidmemberhash", + "name": "John Doe" + } + }, + "type": "addMemberToCard", + "date": "2021-07-10T16:57:39.999Z", + "appCreator": null, + "limits": {}, + "member": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + }, + "memberCreator": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + } + }, + { + "id": "59bbfc4bf36aa0270d6bfd43", + "idMemberCreator": "fakeidmemberhash", + "data": { + "board": { + "shortLink": "qwerty", + "name": "Test Board Name", + "id": "fakeboardidhash" + }, + "list": { + "name": "TODO", + "id": "hashlisttodo" + }, + "card": { + "shortLink": "fakeshortlinkcard7", + "idShort": 7, + "name": "Name Card 7", + "id": "hashcard7" + } + }, + "type": "createCard", + "date": "2017-09-15T16:14:03.187Z", + "appCreator": null, + "limits": {}, + "memberCreator": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + } + }, + { + "id": "59bbfb8e4a6f8ca35be9b82a", + "idMemberCreator": "fakeidmemberhash", + "data": { + "board": { + "shortLink": "qwerty", + "name": "Test Board Name", + "id": "fakeboardidhash" + }, + "list": { + "name": "TODO", + "id": "hashlisttodo" + } + }, + "type": "createList", + "date": "2017-09-15T16:10:54.714Z", + "appCreator": null, + "limits": {}, + "memberCreator": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + } + }, + { + "id": "59bbfb88973b76e586edec5e", + "idMemberCreator": "fakeidmemberhash", + "data": { + "board": { + "shortLink": "qwerty", + "name": "Test Board Name", + "id": "fakeboardidhash" + } + }, + "type": "createBoard", + "date": "2017-09-15T16:10:48.069Z", + "appCreator": null, + "limits": {}, + "memberCreator": { + "id": "fakeidmemberhash", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idMemberReferrer": null, + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true + } + } + ], + "cards": [ + { + "id": "hashcard7", + "address": null, + "checkItemStates": null, + "closed": false, + "coordinates": null, + "creationMethod": null, + "dateLastActivity": "2021-07-10T17:01:58.633Z", + "desc": "", + "descData": null, + "dueReminder": null, + "idBoard": "fakeboardidhash", + "idLabels": [], + "idList": "hashlisttodo", + "idMembersVoted": [], + "idShort": 7, + "idAttachmentCover": null, + "locationName": null, + "manualCoverAttachment": false, + "name": "Name Card 7", + "pos": 65535, + "shortLink": "fakeshortlinkcard7", + "isTemplate": false, + "cardRole": null, + "badges": { + "attachmentsByType": { + "trello": { + "board": 0, + "card": 0 + } + }, + "location": false, + "votes": 0, + "viewingMemberVoted": false, + "subscribed": false, + "fogbugz": "", + "checkItems": 0, + "checkItemsChecked": 0, + "checkItemsEarliestDue": null, + "comments": 0, + "attachments": 0, + "description": false, + "due": null, + "dueComplete": false, + "start": null + }, + "dueComplete": false, + "due": null, + "email": "johndoe+card7@boards.trello.com", + "idChecklists": [], + "idMembers": [], + "labels": [], + "limits": { + "attachments": { + "perCard": { + "status": "ok", + "disableAt": 1000, + "warnAt": 900 + } + }, + "checklists": { + "perCard": { + "status": "ok", + "disableAt": 500, + "warnAt": 450 + } + }, + "stickers": { + "perCard": { + "status": "ok", + "disableAt": 70, + "warnAt": 63 + } + } + }, + "shortUrl": "https://trello.com/c/fakeshortlinkcard7", + "start": null, + "subscribed": false, + "url": "https://trello.com/c/fakeshortlinkcard7/7-name-card-7", + "cover": { + "idAttachment": null, + "color": null, + "idUploadedBackground": null, + "size": "normal", + "brightness": "dark", + "idPlugin": null + }, + "attachments": [], + "pluginData": [], + "customFieldItems": [] + } + ], + "labels": [ + { + "id": "59bbfb881314a339999eb855", + "idBoard": "fakeboardidhash", + "name": "", + "color": "yellow" + } + ], + "lists": [ + { + "id": "hashlisttodo", + "name": "TODO", + "closed": false, + "pos": 65535, + "softLimit": null, + "creationMethod": null, + "idBoard": "fakeboardidhash", + "limits": { + "cards": { + "openPerList": { + "status": "ok", + "disableAt": 5000, + "warnAt": 4500 + }, + "totalPerList": { + "status": "ok", + "disableAt": 1000000, + "warnAt": 900000 + } + } + }, + "subscribed": false + } + ], + "members": [ + { + "id": "fakeidmemberhash", + "bio": "", + "bioData": { + "emoji": {} + }, + "confirmed": true, + "memberType": "normal", + "username": "johndoe", + "activityBlocked": false, + "avatarHash": "fakeavatarhash", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "fullName": "John Doe", + "idEnterprise": null, + "idEnterprisesDeactivated": [], + "idMemberReferrer": null, + "idPremOrgsAdmin": [], + "initials": "JD", + "nonPublic": { + "fullName": "John Doe", + "initials": "JD", + "avatarUrl": "https://trello-members.s3.amazonaws.com/fakeidmemberhash/fakeavatarhash", + "avatarHash": "fakeavatarhash" + }, + "nonPublicAvailable": true, + "products": [], + "url": "https://trello.com/johndoe", + "status": "disconnected" + } + ], + "checklists": [], + "customFields": [], + "memberships": [ + { + "id": "59bbfb88973b76e586edec5d", + "idMember": "fakeidmemberhash", + "memberType": "admin", + "unconfirmed": false, + "deactivated": false + } + ], + "pluginData": [] +} \ No newline at end of file diff --git a/tests/unit/Command/BoardImportTest.php b/tests/unit/Command/BoardImportTest.php new file mode 100644 index 000000000..32701811b --- /dev/null +++ b/tests/unit/Command/BoardImportTest.php @@ -0,0 +1,77 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ + +namespace OCA\Deck\Command; + +use OCA\Deck\Service\Importer\BoardImportCommandService; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class BoardImportTest extends \Test\TestCase { + /** @var BoardImportCommandService */ + private $boardImportCommandService; + /** @var BoardImport */ + private $boardImport; + + public function setUp(): void { + parent::setUp(); + $this->boardImportCommandService = $this->createMock(BoardImportCommandService::class); + $this->boardImport = new BoardImport( + $this->boardImportCommandService + ); + $questionHelper = new QuestionHelper(); + $this->boardImport->setHelperSet( + new HelperSet([ + $questionHelper + ]) + ); + } + + public function testExecuteWithSuccess() { + $input = $this->createMock(InputInterface::class); + $input + ->method('getOption') + ->withConsecutive( + ['system'], + ['config'] + ) + ->will($this->returnValueMap([ + ['system', 'trelloJson'], + ['config', null] + ])); + + $output = $this->createMock(OutputInterface::class); + + $output + ->expects($this->once()) + ->method('writeLn') + ->with('Done!'); + + $actual = $this->invokePrivate($this->boardImport, 'interact', [$input, $output]); + $this->assertNull($actual); + $actual = $this->invokePrivate($this->boardImport, 'execute', [$input, $output]); + $this->assertEquals(0, $actual); + } +} diff --git a/tests/unit/Service/Importer/BoardImportServiceTest.php b/tests/unit/Service/Importer/BoardImportServiceTest.php new file mode 100644 index 000000000..9e4229529 --- /dev/null +++ b/tests/unit/Service/Importer/BoardImportServiceTest.php @@ -0,0 +1,199 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ +namespace OCA\Deck\Service\Importer; + +use OC\Comments\Comment; +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\AclMapper; +use OCA\Deck\Db\Assignment; +use OCA\Deck\Db\AssignmentMapper; +use OCA\Deck\Db\AttachmentMapper; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\LabelMapper; +use OCA\Deck\Db\Stack; +use OCA\Deck\Db\StackMapper; +use OCA\Deck\Event\BoardImportGetAllowedEvent; +use OCA\Deck\Service\Importer\Systems\TrelloJsonService; +use OCP\Comments\ICommentsManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IDBConnection; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; + +class BoardImportServiceTest extends \Test\TestCase { + /** @var IDBConnection|MockObject */ + protected $dbConn; + /** @var IUserManager|MockObject */ + private $userManager; + /** @var BoardMapper|MockObject */ + private $boardMapper; + /** @var AclMapper|MockObject */ + private $aclMapper; + /** @var LabelMapper|MockObject */ + private $labelMapper; + /** @var StackMapper|MockObject */ + private $stackMapper; + /** @var CardMapper|MockObject */ + private $cardMapper; + /** @var AssignmentMapper|MockObject */ + private $assignmentMapper; + /** @var AttachmentMapper|MockObject */ + private $attachmentMapper; + /** @var ICommentsManager|MockObject */ + private $commentsManager; + /** @var IEventDispatcher|MockObject */ + private $eventDispatcher; + /** @var TrelloJsonService|MockObject */ + private $trelloJsonService; + /** @var BoardImportService|MockObject */ + private $boardImportService; + public function setUp(): void { + $this->userManager = $this->createMock(IUserManager::class); + $this->boardMapper = $this->createMock(BoardMapper::class); + $this->aclMapper = $this->createMock(AclMapper::class); + $this->labelMapper = $this->createMock(LabelMapper::class); + $this->stackMapper = $this->createMock(StackMapper::class); + $this->cardMapper = $this->createMock(CardMapper::class); + $this->assignmentMapper = $this->createMock(AssignmentMapper::class); + $this->attachmentMapper = $this->createMock(AttachmentMapper::class); + $this->commentsManager = $this->createMock(ICommentsManager::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->boardImportService = new BoardImportService( + $this->userManager, + $this->boardMapper, + $this->aclMapper, + $this->labelMapper, + $this->stackMapper, + $this->assignmentMapper, + $this->attachmentMapper, + $this->cardMapper, + $this->commentsManager, + $this->eventDispatcher + ); + + $this->boardImportService->setSystem('trelloJson'); + + $this->eventDispatcher + ->method('dispatchTyped') + ->willReturnCallback(function (BoardImportGetAllowedEvent $event) { + $event->getService()->addAllowedImportSystem([ + 'name' => TrelloJsonService::$name, + 'class' => TrelloJsonService::class, + 'internalName' => 'trelloJson' + ]); + }); + + $data = json_decode(file_get_contents(__DIR__ . '/../../../data/data-trelloJson.json')); + $this->boardImportService->setData($data); + + $configFile = __DIR__ . '/../../../data/config-trelloJson.json'; + $configInstance = json_decode(file_get_contents($configFile)); + $this->boardImportService->setConfigInstance($configInstance); + + $this->trelloJsonService = $this->createMock(TrelloJsonService::class); + $this->trelloJsonService + ->method('getJsonSchemaPath') + ->willReturn($configFile); + $this->boardImportService->setImportSystem($this->trelloJsonService); + + $owner = $this->createMock(IUser::class); + $owner + ->method('getUID') + ->willReturn('admin'); + + $johndoe = $this->createMock(IUser::class); + $johndoe + ->method('getUID') + ->willReturn('johndoe'); + $this->userManager + ->method('get') + ->withConsecutive( + ['admin'], + ['johndoe'] + ) + ->willReturnonConsecutiveCalls( + $owner, + $johndoe + ); + } + + public function testImportSuccess() { + $this->boardMapper + ->expects($this->once()) + ->method('insert'); + + $this->trelloJsonService + ->method('getAclList') + ->willReturn([new Acl()]); + $this->aclMapper + ->expects($this->once()) + ->method('insert'); + + $this->trelloJsonService + ->method('getLabels') + ->willReturn([new Label()]); + $this->labelMapper + ->expects($this->once()) + ->method('insert'); + + $this->trelloJsonService + ->method('getStacks') + ->willReturn([new Stack()]); + $this->stackMapper + ->expects($this->once()) + ->method('insert'); + + $this->trelloJsonService + ->method('getCards') + ->willReturn([new Card()]); + $this->cardMapper + ->expects($this->any()) + ->method('insert'); + + $this->trelloJsonService + ->method('getComments') + ->willReturn([ + 'fakecardid' => [new Comment()] + ]); + $this->commentsManager + ->expects($this->once()) + ->method('save'); + + $this->trelloJsonService + ->method('getCardAssignments') + ->willReturn([ + 'fakecardid' => [new Assignment()] + ]); + $this->assignmentMapper + ->expects($this->once()) + ->method('insert'); + + $actual = $this->boardImportService->import(); + + $this->assertNull($actual); + } +} diff --git a/tests/unit/Service/Importer/Systems/TrelloJsonServiceTest.php b/tests/unit/Service/Importer/Systems/TrelloJsonServiceTest.php new file mode 100644 index 000000000..6eec07a9b --- /dev/null +++ b/tests/unit/Service/Importer/Systems/TrelloJsonServiceTest.php @@ -0,0 +1,151 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ +namespace OCA\Deck\Service\Importer\Systems; + +use OCA\Deck\Service\Importer\BoardImportService; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; + +class TrelloJsonServiceTest extends \Test\TestCase { + /** @var TrelloJsonService */ + private $service; + /** @var IURLGenerator|MockObject */ + private $urlGenerator; + /** @var IUserManager|MockObject */ + private $userManager; + /** @var IL10N */ + private $l10n; + public function setUp(): void { + $this->userManager = $this->createMock(IUserManager::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->l10n = $this->createMock(IL10N::class); + $this->service = new TrelloJsonService( + $this->userManager, + $this->urlGenerator, + $this->l10n + ); + } + + public function testValidateUsersWithoutUsers() { + $importService = $this->createMock(BoardImportService::class); + $this->service->setImportService($importService); + $actual = $this->service->validateUsers(); + $this->assertNull($actual); + } + + public function testValidateUsersWithInvalidUser() { + $this->expectErrorMessage('Trello user trello_user not found in property "members" of json data'); + $importService = $this->createMock(BoardImportService::class); + $importService + ->method('getConfig') + ->willReturn([ + 'trello_user' => 'nextcloud_user' + ]); + $importService + ->method('getData') + ->willReturn(json_decode('{"members": [{"username": "othre_trello_user"}]}')); + $this->service->setImportService($importService); + $actual = $this->service->validateUsers(); + $this->assertInstanceOf(BoardImportTrelloJsonService::class, $actual); + } + + public function testValidateUsersWithNotStringNextcloud() { + $this->expectErrorMessage('User on setting uidRelation is invalid'); + $importService = $this->createMock(BoardImportService::class); + $importService + ->method('getConfig') + ->willReturn([ + 'trello_user' => [] + ]); + $importService + ->method('getData') + ->willReturn(json_decode('{"members": [{"username": "trello_user"}]}')); + $this->service->setImportService($importService); + $actual = $this->service->validateUsers(); + $this->assertInstanceOf(BoardImportTrelloJsonService::class, $actual); + } + + public function testValidateUsersWithNotFoundUser() { + $this->expectErrorMessage('User on setting uidRelation not found: nextcloud_user'); + $importService = $this->createMock(BoardImportService::class); + $importService + ->method('getConfig') + ->willReturn(json_decode('{"trello_user": "nextcloud_user"}')); + $importService + ->method('getData') + ->willReturn(json_decode('{"members": [{"username": "trello_user"}]}')); + $this->service->setImportService($importService); + $actual = $this->service->validateUsers(); + $this->assertInstanceOf(BoardImportTrelloJsonService::class, $actual); + } + + public function testValidateUsersWithValidUsers() { + $importService = $this->createMock(BoardImportService::class); + $importService + ->method('getConfig') + ->willReturn(json_decode('{"trello_user": "nextcloud_user"}')); + $importService + ->method('getData') + ->willReturn(json_decode('{"members": [{"id": "fakeid", "username": "trello_user"}]}')); + $fakeUser = $this->createMock(IUser::class); + $this->userManager + ->method('get') + ->with('nextcloud_user') + ->willReturn($fakeUser); + $this->service->setImportService($importService); + $actual = $this->service->validateUsers(); + $this->assertNull($actual); + } + + public function testGetBoardWithNoName() { + $this->expectErrorMessage('Invalid name of board'); + $importService = $this->createMock(BoardImportService::class); + $this->service->setImportService($importService); + $this->service->getBoard(); + } + + public function testGetBoardWithSuccess() { + $importService = \OC::$server->get(BoardImportService::class); + + $data = json_decode(file_get_contents(__DIR__ . '/../../../../data/data-trelloJson.json')); + $importService->setData($data); + + $configInstance = json_decode(file_get_contents(__DIR__ . '/../../../../data/config-trelloJson.json')); + $importService->setConfigInstance($configInstance); + + $owner = $this->createMock(IUser::class); + $owner + ->method('getUID') + ->willReturn('owner'); + $importService->setConfig('owner', $owner); + + $this->service->setImportService($importService); + $actual = $this->service->getBoard(); + $this->assertEquals('Test Board Name', $actual->getTitle()); + $this->assertEquals('owner', $actual->getOwner()); + $this->assertEquals('0800fd', $actual->getColor()); + } +} diff --git a/tests/unit/controller/BoardImportApiControllerTest.php b/tests/unit/controller/BoardImportApiControllerTest.php new file mode 100644 index 000000000..926e4c473 --- /dev/null +++ b/tests/unit/controller/BoardImportApiControllerTest.php @@ -0,0 +1,81 @@ + + * + * @author Vitor Mattos + * + * @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 . + * + */ +namespace OCA\Deck\Controller; + +use OCA\Deck\Db\Board; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\IRequest; +use OCA\Deck\Service\Importer\BoardImportService; + +class BoardImportApiControllerTest extends \Test\TestCase { + private $appName = 'deck'; + private $userId = 'admin'; + /** @var BoardImportApiController */ + private $controller; + /** @var BoardImportService|MockObject */ + private $boardImportService; + + public function setUp(): void { + parent::setUp(); + $this->request = $this->createMock(IRequest::class); + $this->boardImportService = $this->createMock(BoardImportService::class); + + $this->controller = new BoardImportApiController( + $this->appName, + $this->request, + $this->boardImportService, + $this->userId + ); + } + + public function testGetAllowedSystems() { + $allowedSystems = [ + [ + 'name' => '', + 'class' => '', + 'internalName' => 'trelloJson' + ] + ]; + $this->boardImportService + ->method('getAllowedImportSystems') + ->willReturn($allowedSystems); + $actual = $this->controller->getAllowedSystems(); + $expected = new DataResponse($allowedSystems, HTTP::STATUS_OK); + $this->assertEquals($expected, $actual); + } + + public function testImport() { + $system = 'trelloJson'; + $config = [ + 'owner' => 'test' + ]; + $data = [ + 'name' => 'test' + ]; + $actual = $this->controller->import($system, $config, $data); + $board = $this->createMock(Board::class); + $this->assertInstanceOf(Board::class, $board); + $this->assertEquals(HTTP::STATUS_OK, $actual->getStatus()); + } +}