Files
deck/lib/Service/Importer/BoardImportService.php
Carl Schwan 5cf486150a refactor: Fix psalm issues
- Add typing for most of the services, controllers and mappers
- Add api doc for mappers
- Use vendor-bin for psalm
- Use attributes for controllers
- Fix upload of attachments

Signed-off-by: Carl Schwan <carl.schwan@nextcloud.com>
2025-09-28 11:49:06 +02:00

475 lines
14 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
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\DeckJsonService;
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;
use OCP\Server;
use Psr\Log\LoggerInterface;
class BoardImportService {
private string $system = '';
private ?ABoardImportService $systemInstance = null;
private array $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;
private Board $board;
/** @var callable[] */
private array $errorCollectors = [];
/** @var callable[] */
private array $outputCollectors = [];
public function __construct(
private IUserManager $userManager,
private BoardMapper $boardMapper,
private AclMapper $aclMapper,
private LabelMapper $labelMapper,
private StackMapper $stackMapper,
private AssignmentMapper $assignmentMapper,
private AttachmentMapper $attachmentMapper,
private CardMapper $cardMapper,
private ICommentsManager $commentsManager,
private IEventDispatcher $eventDispatcher,
private LoggerInterface $logger,
) {
$this->board = new Board();
$this->disableCommentsEvents();
$this->config = new \stdClass();
}
public function registerErrorCollector(callable $errorCollector): void {
$this->errorCollectors[] = $errorCollector;
}
public function registerOutputCollector(callable $outputCollector): void {
$this->outputCollectors[] = $outputCollector;
}
private function addError(string $message, $exception): void {
$message .= ' (on board ' . $this->getBoard()->getTitle() . ')';
foreach ($this->errorCollectors as $errorCollector) {
$errorCollector($message, $exception);
}
$this->logger->error($message, ['exception' => $exception]);
}
private function addOutput(string $message): void {
foreach ($this->outputCollectors as $outputCollector) {
$outputCollector($message);
}
}
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();
$boards = $this->getImportSystem()->getBoards();
foreach ($boards as $board) {
try {
$this->reset();
$this->setData($board);
$this->importBoard();
$this->importAcl();
$this->importLabels();
$this->importStacks();
$this->importCards();
$this->assignCardsToLabels();
$this->importComments();
$this->importCardAssignments();
} catch (\Throwable $th) {
$this->logger->error('Failed to import board', ['exception' => $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: ' . $this->getSystem());
}
}
/**
* @param ?string $system
* @return self
*/
public function setSystem($system): self {
if ($system) {
$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' => DeckJsonService::$name,
'class' => DeckJsonService::class,
'internalName' => 'DeckJson'
]);
$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 = Server::get($systemClass);
$this->systemInstance->setImportService($this);
}
return $this->systemInstance;
}
public function setImportSystem(ABoardImportService $instance): void {
$this->systemInstance = $instance;
}
public function reset(): void {
$this->board = new Board();
$this->getImportSystem()->reset();
}
public function importBoard(): void {
$board = $this->getImportSystem()->getBoard();
if ($board === null) {
throw new \LogicException('Import board not found');
}
if (!$this->userManager->userExists($board->getOwner())) {
throw new \Exception('Target owner ' . $board->getOwner() . ' not found. Please provide a mapping through the import config.');
}
$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) {
try {
$this->aclMapper->insert($acl);
$this->getImportSystem()->updateAcl($code, $acl);
} catch (\Exception $e) {
$this->addError('Failed to import acl rule for ' . $acl->getParticipant(), $e);
}
}
$this->getBoard()->setAcl($aclList);
}
public function importLabels(): void {
$labels = $this->getImportSystem()->getLabels();
foreach ($labels as $code => $label) {
try {
$this->labelMapper->insert($label);
$this->getImportSystem()->updateLabel($code, $label);
} catch (\Exception $e) {
$this->addError('Failed to import label ' . $label->getTitle(), $e);
}
}
$this->getBoard()->setLabels($labels);
}
public function importStacks(): void {
$stacks = $this->getImportSystem()->getStacks();
foreach ($stacks as $code => $stack) {
try {
$this->stackMapper->insert($stack);
$this->getImportSystem()->updateStack($code, $stack);
} catch (\Exception $e) {
$this->addError('Failed to import list ' . $stack->getTitle(), $e);
}
}
$this->getBoard()->setStacks(array_values($stacks));
}
public function importCards(): void {
$cards = $this->getImportSystem()->getCards();
foreach ($cards as $code => $card) {
try {
$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);
} catch (\Exception $e) {
$this->addError('Failed to import card ' . $card->getTitle(), $e);
}
}
}
public function assignCardToLabel(int $cardId, int $labelId): self {
$this->cardMapper->assignLabel(
$cardId,
$labelId
);
return $this;
}
public function assignCardsToLabels(): void {
$data = $this->getImportSystem()->getCardLabelAssignment();
foreach ($data as $cardId => $assignment) {
foreach ($assignment as $assignmentId => $labelId) {
try {
$this->assignCardToLabel(
(int)$cardId,
$labelId
);
$this->getImportSystem()->updateCardLabelsAssignment((int)$cardId, (int)$assignmentId, $labelId);
} catch (\Exception $e) {
$this->addError('Failed to assign label ' . $labelId . ' to ' . $cardId, $e);
}
}
}
}
public function importComments(): void {
$allComments = $this->getImportSystem()->getComments();
foreach ($allComments as $cardId => $comments) {
foreach ($comments as $commentId => $comment) {
$this->insertComment((int)$cardId, $comment);
$this->getImportSystem()->updateComment((int)$cardId, $commentId, $comment);
}
}
}
private function insertComment(int $cardId, IComment $comment): void {
$comment->setObject('deckCard', (string)$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 || (int)$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 $assignment) {
try {
$assignment = $this->assignmentMapper->insert($assignment);
$this->getImportSystem()->updateCardAssignment((int)$cardId, $assignment->getId(), $assignment);
$this->addOutput('Assignment ' . $assignment->getParticipant() . ' added');
} catch (NotFoundException $e) {
$this->addError('No origin or mapping found for card "' . $cardId . '" and ' . $assignment->getTypeString() . ' assignment "' . $assignment->getParticipant(), $e);
}
}
}
}
public function insertAttachment(Attachment $attachment, string $content): Attachment {
$service = 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();
}
}