diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3397f0ccb..8ee136a7f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -30,7 +30,9 @@ use OCA\Deck\Event\SessionCreatedEvent; use OCA\Deck\Federation\DeckFederationProvider; use OCA\Deck\Import\BoardImporter; +use OCA\Deck\Import\BoardReader; use OCA\Deck\Import\IBoardImporter; +use OCA\Deck\Import\IBoardReader; use OCA\Deck\Listeners\AclCreatedRemovedListener; use OCA\Deck\Listeners\BeforeTemplateRenderedListener; use OCA\Deck\Listeners\CommentEventListener; @@ -141,6 +143,7 @@ public function register(IRegistrationContext $context): void { $context->registerMiddleWare(DefaultBoardMiddleware::class); $context->registerServiceAlias(IBoardImporter::class, BoardImporter::class); + $context->registerServiceAlias(IBoardReader::class, BoardReader::class); $context->registerService('databaseType', static function (ContainerInterface $c) { return $c->get(IConfig::class)->getSystemValue('dbtype', 'sqlite'); diff --git a/lib/Import/BoardReader.php b/lib/Import/BoardReader.php new file mode 100644 index 000000000..6b05d167f --- /dev/null +++ b/lib/Import/BoardReader.php @@ -0,0 +1,382 @@ + self::ASSIGNEE_USER, + Assignment::TYPE_GROUP => self::ASSIGNEE_GROUP, + Assignment::TYPE_CIRCLE => self::ASSIGNEE_CIRCLE, + ]; + + public function __construct( + private IDBConnection $db, + private BoardMapper $boardMapper, + private StackMapper $stackMapper, + private LabelMapper $labelMapper, + private ICommentsManager $commentsManager, + private IUserManager $userManager, + private IGroupManager $groupManager, + private IRootFolder $rootFolder, + private IConfig $config, + ) { + } + + #[\Override] + public function boards(string $userId): array { + if (!$this->userManager->userExists($userId)) { + return []; + } + $ids = $this->boardMapper->findBoardIds($userId); + sort($ids); + $counts = $this->cardCounts($ids); + $boards = []; + foreach ($ids as $id) { + $board = $this->readable($id); + if ($board === null) { + continue; + } + $boards[] = [ + 'id' => $board->getId(), + 'title' => $board->getTitle(), + 'owner' => $board->getOwner(), + 'archived' => (bool)$board->getArchived(), + 'cards' => $counts[$board->getId()] ?? 0, + 'last_modified' => (int)$board->getLastModified(), + ]; + } + return $boards; + } + + #[\Override] + public function board(string $userId, int $boardId): array { + $board = $boardId > 0 && $this->userManager->userExists($userId) && in_array($boardId, $this->boardMapper->findBoardIds($userId), true) + ? $this->readable($boardId) + : null; + if ($board === null) { + throw new ImportException(ImportException::NOT_FOUND, 'The person has no board ' . $boardId); + } + + $labels = []; + foreach ($this->labelMapper->findAll($boardId) as $label) { + $labels[] = ['id' => (int)$label->getId(), 'title' => (string)$label->getTitle(), 'color' => (string)$label->getColor()]; + } + usort($labels, static fn (array $a, array $b): int => $a['id'] <=> $b['id']); + + $stacks = $this->stackMapper->findAll($boardId); + usort($stacks, static fn ($a, $b): int => [(int)$a->getOrder(), (int)$a->getId()] <=> [(int)$b->getOrder(), (int)$b->getId()]); + $cardsByStack = $this->cards(array_map(static fn ($stack): int => (int)$stack->getId(), $stacks)); + + $out = []; + foreach ($stacks as $stack) { + $out[] = [ + 'id' => (int)$stack->getId(), + 'title' => (string)$stack->getTitle(), + 'order' => (int)$stack->getOrder(), + 'cards' => $cardsByStack[(int)$stack->getId()] ?? [], + ]; + } + + return [ + 'id' => $board->getId(), + 'title' => $board->getTitle(), + 'color' => (string)$board->getColor(), + 'owner' => $board->getOwner(), + 'archived' => (bool)$board->getArchived(), + 'labels' => $labels, + 'stacks' => $out, + ]; + } + + /** + * The board, unless it is deleted or of another server + */ + private function readable(int $boardId): ?Board { + try { + $board = $this->boardMapper->find($boardId); + } catch (DoesNotExistException) { + return null; + } + if ($board->getDeletedAt() > 0 || $board->getExternalId() !== null) { + return null; + } + return $board; + } + + /** + * Cards of the boards that are not deleted, in stacks that are not deleted + * + * @param list $boardIds + * @return array by board ID + */ + private function cardCounts(array $boardIds): array { + $counts = []; + foreach (array_chunk($boardIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->select('s.board_id') + ->selectAlias($query->func()->count('c.id'), 'cards') + ->from('deck_cards', 'c') + ->innerJoin('c', 'deck_stacks', 's', $query->expr()->eq('s.id', 'c.stack_id')) + ->where($query->expr()->in('s.board_id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('s.deleted_at', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('c.deleted_at', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->groupBy('s.board_id'); + $result = $query->executeQuery(); + while ($row = $result->fetchAssociative()) { + $counts[(int)$row['board_id']] = (int)$row['cards']; + } + $result->closeCursor(); + } + return $counts; + } + + /** + * The cards of the stacks that are not deleted, archived ones too, in + * Deck's order, with everything on them + * + * @param list $stackIds + * @return array, assignees: list, attachments: list, comments: list}>> by stack ID + */ + private function cards(array $stackIds): array { + if ($stackIds === []) { + return []; + } + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('deck_cards') + ->where($query->expr()->in('stack_id', $query->createNamedParameter($stackIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('deleted_at', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->orderBy('stack_id') + ->addOrderBy('order') + ->addOrderBy('id'); + $result = $query->executeQuery(); + /** @var list $cards */ + $cards = []; + while ($row = $result->fetchAssociative()) { + $cards[] = Card::fromRow($row); + } + $result->closeCursor(); + + $cardIds = array_map(static fn (Card $card): int => (int)$card->getId(), $cards); + $labels = $this->cardLabels($cardIds); + $assignees = $this->assignees($cardIds); + $files = $this->sharedFiles($cardIds); + $deckFiles = $this->deckFiles($cardIds); + + $out = []; + foreach ($cards as $card) { + $id = (int)$card->getId(); + $due = $card->getDuedate(); + $done = $card->getDone(); + $out[(int)$card->getStackId()][] = [ + 'id' => $id, + 'title' => (string)$card->getTitle(), + 'description' => (string)$card->getDescription(), + 'order' => (int)$card->getOrder(), + 'created' => (int)$card->getCreatedAt(), + 'modified' => (int)$card->getLastModified(), + 'due' => $due instanceof \DateTimeInterface ? $due->getTimestamp() : null, + 'done' => $done instanceof \DateTimeInterface ? $done->getTimestamp() : null, + 'archived' => (bool)$card->getArchived(), + 'labels' => $labels[$id] ?? [], + 'assignees' => $assignees[$id] ?? [], + 'attachments' => array_merge($files[$id] ?? [], $deckFiles[$id] ?? []), + 'comments' => $this->comments($id), + ]; + } + return $out; + } + + /** + * @param list $cardIds + * @return array> label IDs by card ID + */ + private function cardLabels(array $cardIds): array { + $out = []; + foreach (array_chunk($cardIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->select('card_id', 'label_id') + ->from('deck_assigned_labels') + ->where($query->expr()->in('card_id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->orderBy('card_id') + ->addOrderBy('label_id'); + $result = $query->executeQuery(); + while ($row = $result->fetchAssociative()) { + $out[(int)$row['card_id']][] = (int)$row['label_id']; + } + $result->closeCursor(); + } + return $out; + } + + /** + * @param list $cardIds + * @return array> by card ID + */ + private function assignees(array $cardIds): array { + $out = []; + foreach (array_chunk($cardIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->select('card_id', 'participant', 'type') + ->from('deck_assigned_users') + ->where($query->expr()->in('card_id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->orderBy('card_id') + ->addOrderBy('id'); + $result = $query->executeQuery(); + while ($row = $result->fetchAssociative()) { + $type = self::ASSIGNEE_TYPES[(int)$row['type']] ?? null; + if ($type === null) { + continue; + } + $participant = (string)$row['participant']; + $out[(int)$row['card_id']][] = ['type' => $type, 'id' => $participant, 'display_name' => $this->displayName($type, $participant)]; + } + $result->closeCursor(); + } + return $out; + } + + private function displayName(string $type, string $id): string { + $name = match ($type) { + self::ASSIGNEE_USER => $this->userManager->getDisplayName($id), + self::ASSIGNEE_GROUP => $this->groupManager->getDisplayName($id), + default => null, + }; + return $name ?? $id; + } + + /** + * Files shared to the cards, described from the file cache; shares + * whose file is gone or that were removed (no permissions) are left out + * + * @param list $cardIds + * @return array> by card ID + */ + private function sharedFiles(array $cardIds): array { + $out = []; + foreach (array_chunk($cardIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->select('s.share_with', 'f.fileid', 'f.name', 'f.size') + ->from('share', 's') + ->innerJoin('s', 'filecache', 'f', $query->expr()->eq('s.file_source', 'f.fileid')) + ->where($query->expr()->eq('s.share_type', $query->createNamedParameter(IShare::TYPE_DECK, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->in('s.share_with', $query->createNamedParameter(array_map('strval', $chunk), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->isNull('s.parent')) + ->andWhere($query->expr()->gt('s.permissions', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->in('s.item_type', $query->createNamedParameter(['file', 'folder'], IQueryBuilder::PARAM_STR_ARRAY))) + ->orderBy('s.id'); + $result = $query->executeQuery(); + while ($row = $result->fetchAssociative()) { + $out[(int)$row['share_with']][] = [ + 'type' => self::ATTACHMENT_FILE, + 'file_id' => (int)$row['fileid'], + 'name' => (string)$row['name'], + 'size' => max(0, (int)$row['size']), + ]; + } + $result->closeCursor(); + } + return $out; + } + + /** + * Attachments in Deck's own storage, with their size from the file cache + * + * @param list $cardIds + * @return array> by card ID + */ + private function deckFiles(array $cardIds): array { + $rows = []; + foreach (array_chunk($cardIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->select('card_id', 'data') + ->from('deck_attachment') + ->where($query->expr()->in('card_id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('deleted_at', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter(self::ATTACHMENT_DECK_FILE))) + ->orderBy('id'); + $result = $query->executeQuery(); + while ($row = $result->fetchAssociative()) { + $rows[] = [(int)$row['card_id'], (string)$row['data']]; + } + $result->closeCursor(); + } + if ($rows === []) { + return []; + } + + $storage = $this->rootFolder->getMountPoint()->getNumericStorageId(); + $appData = 'appdata_' . $this->config->getSystemValueString('instanceid') . '/deck/'; + $out = []; + foreach ($rows as [$cardId, $name]) { + $file = ['fileid' => 0, 'size' => 0]; + if ($storage !== null) { + $query = $this->db->getQueryBuilder(); + $query->select('fileid', 'size') + ->from('filecache') + ->where($query->expr()->eq('storage', $query->createNamedParameter($storage, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('path_hash', $query->createNamedParameter(md5($appData . 'file-card-' . $cardId . '/' . $name)))); + $result = $query->executeQuery(); + $file = $result->fetchAssociative() ?: $file; + $result->closeCursor(); + } + $out[$cardId][] = [ + 'type' => self::ATTACHMENT_DECK_FILE, + 'file_id' => (int)$file['fileid'], + 'name' => $name, + 'size' => max(0, (int)$file['size']), + ]; + } + return $out; + } + + /** + * A card's comments, oldest first + * + * @return list + */ + private function comments(int $cardId): array { + $comments = $this->commentsManager->getForObject(self::COMMENT_OBJECT, (string)$cardId); + $out = []; + foreach (array_reverse($comments) as $comment) { + $actorType = $comment->getActorType(); + $actorId = $comment->getActorId(); + $out[] = [ + 'actor_type' => $actorType, + 'actor_id' => $actorId, + 'display_name' => $actorType === 'users' ? ($this->userManager->getDisplayName($actorId) ?? $actorId) : $actorId, + 'created' => $comment->getCreationDateTime()->getTimestamp(), + 'text' => $comment->getMessage(), + ]; + } + return $out; + } +} diff --git a/lib/Import/IBoardReader.php b/lib/Import/IBoardReader.php new file mode 100644 index 000000000..1b6c54e3b --- /dev/null +++ b/lib/Import/IBoardReader.php @@ -0,0 +1,67 @@ + + */ + public function boards(string $userId): array; + + /** + * One board of {@see self::boards()}, whole + * + * @return array{id: int, title: string, color: string, owner: string, archived: bool, + * labels: list, + * stacks: list, + * assignees: list, + * attachments: list, + * comments: list, + * }>}>} + * @throws ImportException NOT_FOUND when the person doesn't have the board + */ + public function board(string $userId, int $boardId): array; +} diff --git a/lib/Import/ImportException.php b/lib/Import/ImportException.php index 704430881..473c79aed 100644 --- a/lib/Import/ImportException.php +++ b/lib/Import/ImportException.php @@ -10,12 +10,12 @@ namespace OCA\Deck\Import; /** - * A call the importer refuses as a whole + * A call the importer or the reader refuses as a whole * * @since 2.0.0 */ class ImportException extends \Exception { - /** The board doesn't exist */ + /** The board doesn't exist, or the person doesn't have it ({@see IBoardReader}) */ public const NOT_FOUND = 'not_found'; /** The importing app didn't create the board */ public const NOT_IMPORTER = 'not_importer'; diff --git a/tests/integration/import/BoardReaderTest.php b/tests/integration/import/BoardReaderTest.php new file mode 100644 index 000000000..6f0e71c7e --- /dev/null +++ b/tests/integration/import/BoardReaderTest.php @@ -0,0 +1,261 @@ + card ID by ref */ + private array $cards = []; + private int $fileId; + private string $fileName; + + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + foreach (['deck', 'activity', 'notifications'] as $app) { + Server::get(IAppManager::class)->loadApp($app); + } + $backend = new \Test\Util\User\Dummy(); + Server::get(IUserManager::class)->registerBackend($backend); + foreach ([self::OWNER, self::ALICE, self::BOB, self::CAROL] as $uid) { + $backend->createUser($uid, $uid); + } + $groupBackend = new \Test\Util\Group\Dummy(); + $groupBackend->createGroup(self::GROUP); + $groupBackend->addToGroup(self::BOB, self::GROUP); + Server::get(IGroupManager::class)->addBackend($groupBackend); + + $qb = Server::get(IDBConnection::class)->getQueryBuilder(); + $qb->delete('mounts') + ->where($qb->expr()->in('user_id', $qb->createNamedParameter([self::OWNER, self::ALICE, self::BOB, self::CAROL], IQueryBuilder::PARAM_STR_ARRAY))) + ->executeStatement(); + } + + protected function setUp(): void { + parent::setUp(); + $this->reader = Server::get(IBoardReader::class); + $this->importer = Server::get(IBoardImporter::class); + $this->db = Server::get(IDBConnection::class); + Server::get(IUserSession::class)->setUser(null); + + $this->fileName = 'Budget ' . $this->getUniqueID() . '.txt'; + $this->fileId = Server::get(IRootFolder::class)->getUserFolder(self::OWNER)->newFile($this->fileName, 'numbers')->getId(); + + $board = $this->importer->ensureBoard(self::APP, 'read-' . $this->getUniqueID(), self::OWNER, 'Launch plan', '0082c9', + new \DateTimeImmutable('2025-10-01T10:00:00Z'), + [new ImportLabel('urgent', 'Urgent', 'e9322d'), new ImportLabel('later', 'Later', 'f1db50')], + [new ImportStack('todo', 'To do', 0), new ImportStack('done', 'Done', 1)], + ); + $this->assertSame(ImportResult::STATUS_OK, $board['status']); + $this->boardId = $board['board_id']; + + $results = $this->importer->importCards(self::APP, $this->boardId, [ + $this->card('task', 'todo', 'Ship it', 0, ['assignees' => [self::ALICE], 'labels' => ['urgent'], 'files' => [$this->fileId], + 'due' => new \DateTimeImmutable('2025-11-20T16:00:00Z')]), + $this->card('second', 'todo', 'Second', 1), + $this->card('finished', 'done', 'Finished', 0, ['done' => new \DateTimeImmutable('2025-11-19T12:00:00Z')]), + $this->card('gone', 'done', 'Removed', 1), + ]); + foreach (['task', 'second', 'finished', 'gone'] as $i => $ref) { + $this->assertSame(ImportResult::STATUS_OK, $results[$i]->status, (string)$results[$i]->errorMessage); + $this->cards[$ref] = (int)$results[$i]->cardId; + } + $this->importer->removeCards(self::APP, $this->boardId, ['gone']); + $this->importer->setAccess(self::APP, $this->boardId, [ + new ImportAccess(ImportAccess::TYPE_USER, self::ALICE, true, false, false), + new ImportAccess(ImportAccess::TYPE_GROUP, self::GROUP, false, false, false), + ]); + + $cardMapper = Server::get(CardMapper::class); + $second = $cardMapper->find($this->cards['second'], false); + $second->setArchived(true); + $cardMapper->update($second); + + $comments = Server::get(ICommentsManager::class); + foreach (['First comment', 'Second comment'] as $i => $text) { + $comment = $comments->create('users', self::BOB, 'deckCard', (string)$this->cards['task']); + $comment->setMessage($text); + $comment->setVerb('comment'); + $comment->setCreationDateTime(new \DateTime('2025-11-0' . ($i + 2) . 'T09:00:00Z')); + $comments->save($comment); + } + + $attachment = new Attachment(); + $attachment->setCardId($this->cards['task']); + $attachment->setType(IBoardReader::ATTACHMENT_DECK_FILE); + $attachment->setData('legacy.pdf'); + $attachment->setCreatedBy(self::OWNER); + $attachment->setLastModified(time()); + $attachment->setCreatedAt(time()); + Server::get(AttachmentMapper::class)->insert($attachment); + Server::get(BoardMapper::class)->flushCache(); + } + + protected function tearDown(): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('comments') + ->where($qb->expr()->eq('object_type', $qb->createNamedParameter('deckCard'))) + ->andWhere($qb->expr()->in('object_id', $qb->createNamedParameter(array_map('strval', array_values($this->cards)), IQueryBuilder::PARAM_STR_ARRAY))) + ->executeStatement(); + parent::tearDown(); + } + + public function testBoardsOwnedOrSharedDirectlyOrThroughAGroup(): void { + foreach ([self::OWNER, self::ALICE, self::BOB] as $uid) { + $boards = array_values(array_filter($this->reader->boards($uid), fn (array $b): bool => $b['id'] === $this->boardId)); + $this->assertCount(1, $boards, $uid); + $this->assertSame('Launch plan', $boards[0]['title']); + $this->assertSame(self::OWNER, $boards[0]['owner']); + $this->assertSame(3, $boards[0]['cards'], 'the removed card is not counted'); + } + $this->assertSame([], array_filter($this->reader->boards(self::CAROL), fn (array $b): bool => $b['id'] === $this->boardId)); + $this->assertSame([], $this->reader->boards('deck-read-nobody')); + } + + public function testABoardWithCardsCommentsAndAttachments(): void { + $board = $this->reader->board(self::BOB, $this->boardId); + $this->assertSame('0082c9', $board['color']); + $this->assertSame(['Urgent', 'Later'], array_map(static fn (array $l): string => $l['title'], $board['labels'])); + $this->assertSame(['To do', 'Done'], array_map(static fn (array $s): string => $s['title'], $board['stacks'])); + + [$todo, $done] = $board['stacks']; + $this->assertSame(['Ship it', 'Second'], array_map(static fn (array $c): string => $c['title'], $todo['cards'])); + $this->assertSame(['Finished'], array_map(static fn (array $c): string => $c['title'], $done['cards']), 'removed cards are left out'); + $this->assertTrue($todo['cards'][1]['archived']); + $this->assertSame(strtotime('2025-11-19T12:00:00Z'), $done['cards'][0]['done']); + + $task = $todo['cards'][0]; + $this->assertSame(strtotime('2025-11-20T16:00:00Z'), $task['due']); + $urgent = array_values(array_filter($board['labels'], static fn (array $l): bool => $l['title'] === 'Urgent'))[0]; + $this->assertSame([$urgent['id']], $task['labels']); + $this->assertSame([['type' => 'user', 'id' => self::ALICE, 'display_name' => self::ALICE]], $task['assignees']); + $this->assertSame(['First comment', 'Second comment'], array_map(static fn (array $c): string => $c['text'], $task['comments'])); + $this->assertSame(self::BOB, $task['comments'][0]['actor_id']); + $this->assertSame(strtotime('2025-11-02T09:00:00Z'), $task['comments'][0]['created']); + + $this->assertCount(2, $task['attachments']); + $this->assertSame(['type' => 'file', 'file_id' => $this->fileId, 'name' => $this->fileName, 'size' => 7], $task['attachments'][0]); + $this->assertSame('deck_file', $task['attachments'][1]['type']); + $this->assertSame('legacy.pdf', $task['attachments'][1]['name']); + } + + public function testOnlyThePersonsBoards(): void { + foreach ([[self::CAROL, $this->boardId], [self::BOB, 0], [self::BOB, $this->boardId + 100000]] as [$uid, $id]) { + try { + $this->reader->board($uid, $id); + $this->fail('read board ' . $id . ' as ' . $uid); + } catch (ImportException $e) { + $this->assertSame(ImportException::NOT_FOUND, $e->getReason()); + } + } + } + + public function testReadingChangesNothing(): void { + $before = $this->rows(); + $events = []; + $listener = function (Event $event) use (&$events): void { + $events[] = $event::class; + }; + $dispatcher = Server::get(IEventDispatcher::class); + $watched = [ + \OCA\Deck\Event\CardUpdatedEvent::class, + \OCA\Deck\Event\BoardUpdatedEvent::class, + \OCA\Deck\Event\SessionCreatedEvent::class, + \OCP\Comments\CommentsEntityEvent::class, + ]; + foreach ($watched as $class) { + $dispatcher->addListener($class, $listener); + } + + foreach ([self::OWNER, self::ALICE, self::BOB] as $uid) { + $this->reader->boards($uid); + $this->reader->board($uid, $this->boardId); + } + + foreach ($watched as $class) { + $dispatcher->removeListener($class, $listener); + } + $this->assertSame([], $events); + $this->assertSame($before, $this->rows()); + } + + /** + * Rows and last changes of the tables reading could touch + * + * @return array + */ + private function rows(): array { + $out = []; + foreach (self::QUIET_TABLES as $table) { + if (!$this->db->tableExists($table)) { + continue; + } + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*'))->from($table); + $result = $qb->executeQuery(); + $out[$table] = (int)$result->fetchOne(); + $result->closeCursor(); + } + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'last_modified', 'archived')->from('deck_cards') + ->where($qb->expr()->in('id', $qb->createNamedParameter(array_values($this->cards), IQueryBuilder::PARAM_INT_ARRAY))) + ->orderBy('id'); + $result = $qb->executeQuery(); + $out['cards'] = $result->fetchAll(); + $result->closeCursor(); + $out['board'] = Server::get(BoardMapper::class)->find($this->boardId)->getLastModified(); + return $out; + } + + /** + * @param array $fields + */ + private function card(string $ref, string $stackRef, string $title, int $order, array $fields = []): ImportCard { + $fields += ['due' => null, 'done' => null, 'assignees' => [], 'labels' => [], 'files' => []]; + $hash = CardCanonicalForm::hash($ref, $stackRef, $title, '', $order, null, $fields['due'], $fields['done'], + $fields['assignees'], $fields['labels'], $fields['files']); + return new ImportCard($ref, $stackRef, $title, '', $order, null, $fields['due'], $fields['done'], + new \DateTimeImmutable('2025-11-01T08:00:00Z'), '', $fields['assignees'], $fields['labels'], $fields['files'], $hash); + } +}