diff --git a/lib/Command/BoardImport.php b/lib/Command/BoardImport.php new file mode 100644 index 000000000..296c08bf8 --- /dev/null +++ b/lib/Command/BoardImport.php @@ -0,0 +1,424 @@ + + * + * @author Julius Härtl + * + * @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 JsonSchema\Validator; +use OCA\Deck\Db\AssignmentMapper; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\Stack; +use OCA\Deck\Db\StackMapper; +use OCA\Deck\Service\BoardService; +use OCA\Deck\Service\LabelService; +use OCA\Deck\Service\PermissionService; +use OCA\Deck\Service\StackService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\IUserSession; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\Question; + +class BoardImport extends Command { + /** @var BoardService */ + private $boardService; + // protected $cardMapper; + /** @var LabelService */ + private $labelService; + /** @var StackMapper */ + private $stackMapper; + /** @var CardMapper */ + private $cardMapper; + /** @var IUserManager */ + private $userManager; + // /** @var IGroupManager */ + // private $groupManager; + // private $assignedUsersMapper; + private $allowedSystems = ['trello']; + /** @var Board */ + private $board; + + public function __construct( + // BoardMapper $boardMapper, + BoardService $boardService, + LabelService $labelService, + StackMapper $stackMapper, + CardMapper $cardMapper, + // IUserSession $userSession, + // StackMapper $stackMapper, + // CardMapper $cardMapper, + // AssignmentMapper $assignedUsersMapper, + IUserManager $userManager + // IGroupManager $groupManager + ) { + parent::__construct(); + + // $this->cardMapper = $cardMapper; + $this->boardService = $boardService; + $this->labelService = $labelService; + $this->stackMapper = $stackMapper; + $this->cardMapper = $cardMapper; + + // $this->userSession = $userSession; + // $this->stackMapper = $stackMapper; + // $this->assignedUsersMapper = $assignedUsersMapper; + // $this->boardMapper = $boardMapper; + + $this->userManager = $userManager; + // $this->groupManager = $groupManager; + } + + protected function configure() { + $this + ->setName('deck:import') + ->setDescription('Import data') + ->addOption( + 'system', + null, + InputOption::VALUE_REQUIRED, + 'Source system for import. Available options: trello.', + 'trello' + ) + ->addOption( + 'setting', + null, + InputOption::VALUE_REQUIRED, + 'Configuration json file.', + 'config.json' + ) + ->addOption( + 'data', + null, + InputOption::VALUE_REQUIRED, + 'Data file to import.', + 'data.json' + ) + ; + } + + /** + * @inheritDoc + */ + protected function interact(InputInterface $input, OutputInterface $output) + { + $this->validateSystem($input, $output); + $this->validateData($input, $output); + $this->validateSettings($input, $output); + $this->validateUsers(); + $this->validateOwner(); + } + + public function validateData(InputInterface $input, OutputInterface $output) { + $filename = $input->getOption('data'); + if (!is_file($filename)) { + $helper = $this->getHelper('question'); + $question = new Question( + 'Please inform a valid data json file: ', + 'data.json' + ); + $question->setValidator(function ($answer) { + if (!is_file($answer)) { + throw new \RuntimeException( + 'Data file not found' + ); + } + return $answer; + }); + $data = $helper->ask($input, $output, $question); + $input->setOption('data', $data); + } + $this->data = json_decode(file_get_contents($filename)); + if (!$this->data) { + $output->writeln('Is not a json file: ' . $filename . ''); + $this->validateData($input, $output); + } + if (!$this->data) { + $this->data = json_decode(file_get_contents($filename)); + } + } + + private function validateOwner() { + $this->settings->owner = $this->userManager->get($this->settings->owner); + if (!$this->settings->owner) { + throw new \LogicException('Owner "' . $this->settings->owner . '" not found on Nextcloud. Check setting json.'); + } + } + + private function validateUsers() { + if (empty($this->settings->uidRelation)) { + return; + } + foreach ($this->settings->uidRelation as $trelloUid => $nextcloudUid) { + $user = array_filter($this->data->members, fn($u) => $u->username === $trelloUid); + if (!$user) { + throw new \LogicException('Trello user ' . $trelloUid . ' not found in property "members" of json data'); + } + if (!is_string($nextcloudUid)) { + throw new \LogicException('User on setting uidRelation must be a string'); + } + $this->settings->uidRelation->$trelloUid = $this->userManager->get($nextcloudUid); + if (!$this->settings->uidRelation->$trelloUid) { + throw new \LogicException('User on setting uidRelation not found: ' . $nextcloudUid); + } + } + } + + private function validateSystem(InputInterface $input, OutputInterface $output) { + if (in_array($input->getOption('system'), $this->allowedSystems)) { + return; + } + $helper = $this->getHelper('question'); + $question = new ChoiceQuestion( + 'Please inform a source system', + $this->allowedSystems, + 0 + ); + $question->setErrorMessage('System %s is invalid.'); + $system = $helper->ask($input, $output, $question); + $input->setOption('system', $system); + } + + private function validateSettings(InputInterface $input, OutputInterface $output) { + if (!is_file($input->getOption('setting'))) { + $helper = $this->getHelper('question'); + $question = new Question( + 'Please inform a valid setting json file: ', + 'config.json' + ); + $question->setValidator(function ($answer) { + if (!is_file($answer)) { + throw new \RuntimeException( + 'Setting file not found' + ); + } + return $answer; + }); + $setting = $helper->ask($input, $output, $question); + $input->setOption('setting', $setting); + } + + $this->settings = json_decode(file_get_contents($input->getOption('setting'))); + $validator = new Validator(); + $validator->validate( + $this->settings, + (object)['$ref' => 'file://' . realpath(__DIR__ . '/fixtures/setting-schema.json')] + ); + if (!$validator->isValid()) { + $output->writeln('Invalid setting file'); + $output->writeln(array_map(fn($v) => $v['message'], $validator->getErrors())); + $output->writeln('Valid schema:'); + $output->writeln(print_r(file_get_contents(__DIR__ . '/fixtures/setting-schema.json'), true)); + $input->setOption('setting', null); + $this->validateSettings($input, $output); + } + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return void + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws \ReflectionException + */ + protected function execute(InputInterface $input, OutputInterface $output) { + // $this->boardService->setUserId($this->settings->owner->getUID()); + $this->setUserId($this->settings->owner->getUID()); + // $this->userSession->setUser($this->settings->owner); + $this->importBoard(); + $this->importLabels(); + $this->importStacks(); + $this->importCards(); + // $boards = $this->boardService->findAll(); + + // $data = []; + // foreach ($boards as $board) { + // $fullBoard = $this->boardMapper->find($board->getId(), true, true); + // $data[$board->getId()] = (array)$fullBoard->jsonSerialize(); + // $stacks = $this->stackMapper->findAll($board->getId()); + // foreach ($stacks as $stack) { + // $data[$board->getId()]['stacks'][] = (array)$stack->jsonSerialize(); + // $cards = $this->cardMapper->findAllByStack($stack->getId()); + // foreach ($cards as $card) { + // $fullCard = $this->cardMapper->find($card->getId()); + // $assignedUsers = $this->assignedUsersMapper->findAll($card->getId()); + // $fullCard->setAssignedUsers($assignedUsers); + // $data[$board->getId()]['stacks'][$stack->getId()]['cards'][] = (array)$fullCard->jsonSerialize(); + // } + // } + // } + // $output->writeln(json_encode($data, JSON_PRETTY_PRINT)); + return self::SUCCESS; + } + + private function checklistItem($item) { + if (($item->state == 'incomplete')) { + $string_start = '- [ ]'; + } else { + $string_start = '- [x]'; + } + $check_item_string = $string_start . ' ' . $item->name . "\n"; + return $check_item_string; + } + + function formulateChecklistText($checklist) { + $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 importCards() { + # Save checklist content into a dictionary (_should_ work even if a card has multiple checklists + foreach ($this->data->checklists as $checklist) { + $checklists[$checklist->idCard][$checklist->id] = $this->formulateChecklistText($checklist); + } + $this->data->checklists = $checklists; + + foreach ($this->data->cards as $trelloCard) { + # Check whether a card is archived, if true, skipping to the next card + if ($trelloCard->closed) { + continue; + } + if ((count($trelloCard->idChecklists) !== 0)) { + foreach ($this->data->checklists[$trelloCard->id] as $checklist) { + $trelloCard->desc .= "\n" . $checklist; + } + } + + $card = new Card(); + $card->setTitle($trelloCard->name); + $card->setStackId($this->stacks[$trelloCard->idList]); + $card->setType('plain'); + $card->setOrder($trelloCard->idShort); + $card->setOwner($this->settings->owner->getUID()); + $card->setDescription($trelloCard->desc); + if ($trelloCard->due) { + $duedate = \DateTime::createFromFormat('Y-m-d\TH:i:s.000\Z', $trelloCard->due) + ->format('Y-m-d H:i:s'); + $card->setDuedate($duedate); + } + $card = $this->cardMapper->insert($card); + + $this->associateCardToLabels($card->getId(), $trelloCard); + } + } + + public function associateCardToLabels($cardId, $card) { + foreach ($card->labels as $label) { + $this->cardMapper->assignLabel( + $cardId, + $this->labels[$label->id]->getId() + ); + } + } + + private function importStacks() { + $this->stacks = []; + foreach ($this->data->lists as $order => $list) { + if ($list->closed) { + continue; + } + $stack = new Stack(); + $stack->setTitle($list->name); + $stack->setBoardId($this->board->getId()); + $stack->setOrder($order + 1); + $stack = $this->stackMapper->insert($stack); + $this->stacks[$list->id] = $stack; + } + } + + private function translateColor($color) { + 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 importBoard() { + $this->board = $this->boardService->create( + $this->data->name, + $this->settings->owner->getUID(), + $this->settings->color + ); + // $this->boardService->find($this->board->getId()); + } + + public function importLabels() { + $this->labels = []; + foreach ($this->data->labels as $label) { + if (empty($label->name)) { + $labelTitle = 'Unnamed ' . $label->color . ' label'; + } else { + $labelTitle = $label->name; + } + $newLabel = $this->labelService->create( + $labelTitle, + $this->translateColor($label->color), + $this->board->getId() + ); + $this->labels[$label->id] = $newLabel; + } + } + + private function setUserId() { + $propertyPermissionService = new \ReflectionProperty($this->labelService, 'permissionService'); + $propertyPermissionService->setAccessible(true); + $permissionService = $propertyPermissionService->getValue($this->labelService); + + $propertyUserId = new \ReflectionProperty($permissionService, 'userId'); + $propertyUserId->setAccessible(true); + $propertyUserId->setValue($permissionService, $this->settings->owner->getUID()); + } +} diff --git a/lib/Command/fixtures/setting-schema.json b/lib/Command/fixtures/setting-schema.json new file mode 100644 index 000000000..b21501c2b --- /dev/null +++ b/lib/Command/fixtures/setting-schema.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "properties": { + "uidRelation": { + "type": "object" + }, + "owner": { + "type": "string", + "required": true + }, + "color": { + "type": "string", + "required": true, + "pattern": "^[0-9a-fA-F]{6}$" + } + } +} \ No newline at end of file