From 2dd2603b89456dffb7c5af715504aa93d6e9b154 Mon Sep 17 00:00:00 2001 From: Timo Reusch Date: Sat, 22 Aug 2026 01:48:08 +0200 Subject: [PATCH 1/3] feat(export): export and import complete board state Signed-off-by: Timo Reusch --- .gitignore | 68 ++ docs/export-import.md | 67 +- lib/Command/UserExport.php | 60 +- lib/Controller/BoardController.php | 22 +- lib/Db/AttachmentMapper.php | 30 + lib/Service/BoardExportOptions.php | 26 + lib/Service/BoardExportService.php | 363 ++++++++++ lib/Service/BoardService.php | 50 -- lib/Service/Importer/ABoardImportService.php | 10 + .../Importer/BoardImportCommandService.php | 43 +- lib/Service/Importer/BoardImportService.php | 56 +- lib/Service/Importer/ImportOptions.php | 59 ++ .../Importer/Systems/DeckJsonService.php | 87 ++- .../ShareFileAttachmentExportService.php | 63 +- lib/UserMigration/DeckMigrator.php | 117 +--- package.json | 2 +- .../navigation/AppNavigationBoard.vue | 12 +- .../navigation/AppNavigationImportBoard.vue | 40 +- .../navigation/BoardExportModal.vue | 40 +- .../navigation/BoardImportModal.vue | 122 ++++ src/helpers/__tests__/boardExport.spec.js | 318 +++++++++ src/helpers/boardExport.js | 177 +++++ src/services/BoardApi.js | 135 ++-- src/stores/board.js | 4 +- tests/data/deck-complete.json | 150 ++++ tests/data/deck.json | 26 +- tests/integration/import/ImportExportTest.php | 70 +- tests/unit/Command/UserExportTest.php | 252 +++---- tests/unit/Db/AttachmentMapperTest.php | 47 ++ .../Middleware/ExceptionMiddlewareTest.php | 3 +- tests/unit/Service/BoardExportServiceTest.php | 650 ++++++++++++++++++ .../BoardImportCommandServiceTest.php | 218 ++++++ .../Importer/BoardImportServiceTest.php | 136 ++++ .../Service/Importer/ImportOptionsTest.php | 97 +++ .../Importer/Systems/DeckJsonServiceTest.php | 318 ++++++++- .../ShareFileAttachmentExportServiceTest.php | 181 +++++ tests/unit/UserMigration/DeckMigratorTest.php | 210 ++++-- tests/unit/controller/BoardControllerTest.php | 190 +++++ 38 files changed, 3910 insertions(+), 609 deletions(-) create mode 100644 lib/Service/BoardExportOptions.php create mode 100644 lib/Service/BoardExportService.php create mode 100644 lib/Service/Importer/ImportOptions.php create mode 100644 src/components/navigation/BoardImportModal.vue create mode 100644 src/helpers/__tests__/boardExport.spec.js create mode 100644 src/helpers/boardExport.js create mode 100644 tests/data/deck-complete.json create mode 100644 tests/unit/Service/BoardExportServiceTest.php create mode 100644 tests/unit/Service/Importer/BoardImportCommandServiceTest.php create mode 100644 tests/unit/Service/Importer/ImportOptionsTest.php create mode 100644 tests/unit/Service/ShareFileAttachmentExportServiceTest.php diff --git a/.gitignore b/.gitignore index 0dea4da97b..a6ee1355cd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,71 @@ vendor/ .php_cs.cache \.idea/ settings.json + +### Claude Code ### +CLAUDE.md +claude.log + +# OS-Files +# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,visualstudiocode +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk +# End of https://www.toptal.com/developers/gitignore/api/linux,windows,visualstudiocode diff --git a/docs/export-import.md b/docs/export-import.md index e63ddb187b..d7c1c412bc 100644 --- a/docs/export-import.md +++ b/docs/export-import.md @@ -4,21 +4,76 @@ --> ## Export -Deck currently supports exporting all boards a user owns in a single JSON file. The format is based on the database schema that Deck uses. It can be used to re-import boards on the same or other instances. - -The export currently has some known limitations in terms of specific data not included: +Deck supports exporting boards to a single JSON file. The format is based on the database schema that Deck uses. It can be used to re-import boards on the same or other instances. + +The export is a complete representation of a board and contains: +- lists, including which one is configured as the done column +- cards, including archived ones, with their card ID and list ID +- the completion state and the date a card was completed +- due date, start date, creation date and last modification date +- card colour and card type +- dependencies between cards +- labels, assigned users, comments and file attachments + +On import, card dependencies are remapped to the newly created cards. A +dependency that points at a card outside the import - one on another board, or +one skipped because archived cards were deselected - is dropped instead of +leaving a dangling reference behind. + +Dates are exported as ISO 8601 including the UTC offset, so they keep pointing at +the same point in time no matter which timezone imports or reads them. + +Known limitations, this data is not part of an export: - Activity information -- File attachments to Deck cards -- Comments +- Cards in the trash bin + +### From the web interface + +Open the board menu, choose *Export board* and pick a format: + +- **JSON** – the complete board, suited for importing back into Deck. +- **CSV** – one row per card with the card ID, list ID, list name, tags, assigned + users, archived and completion state, all date fields and the comment and + attachment counts. Suited for reporting and for external tools such as + spreadsheets or BI tools. A CSV cannot be imported back into Deck. + +The CSV follows RFC 4180: comma separated, every field quoted, inner quotes +doubled, and UTF-8 with a byte order mark. A separator, a semicolon or the line +breaks of a markdown description can therefore appear inside a cell without +breaking the file. + +Exports are machine readable output, so nothing in them is translated. The column +headers are always English and archived and completed are written as `1` and `0`, +which means a report keeps working no matter which interface language the +exporting user has. The JSON export behaves the same way, its keys being the +English property names. + +Attachment contents are never part of a CSV, but the attachment count is, so the +column stays meaningful. + +Attachment contents are embedded in the JSON export, which can make the file +large. They can be left out in the export dialog, at the cost of an export that +no longer restores the board completely. + +### From the command line ``` occ deck:export userid > userid-deck-export.json ``` *(`userid` = username as seen in the admin user accounts page)* +Pass `--no-attachments` to leave the attachment contents out of the export. + ## Import Boards -Importing can be done using the API or the `occ` `deck:import` command. +Importing can be done from the web interface, using the API or the `occ` +`deck:import` command. + +When importing a board through the web interface, a dialog offers to select which +parts of the file to restore: cards, archived cards, completion state, due and +start dates, tags, assigned users, comments, attachments and sharing. Lists and +labels are always created, so deselecting cards results in an empty copy of the +board that can be used as a template. It is possible to import from the following sources: diff --git a/lib/Command/UserExport.php b/lib/Command/UserExport.php index 7bbe53576e..f87034d32f 100644 --- a/lib/Command/UserExport.php +++ b/lib/Command/UserExport.php @@ -7,29 +7,22 @@ namespace OCA\Deck\Command; -use OCA\Deck\Db\AssignmentMapper; -use OCA\Deck\Db\BoardMapper; -use OCA\Deck\Db\CardMapper; -use OCA\Deck\Db\StackMapper; -use OCA\Deck\Model\CardDetails; +use OCA\Deck\Service\BoardExportOptions; +use OCA\Deck\Service\BoardExportService; use OCA\Deck\Service\BoardService; -use OCA\Deck\Service\CommentService; use OCP\App\IAppManager; use OCP\DB\Exception; 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; class UserExport extends Command { public function __construct( private IAppManager $appManager, - private BoardMapper $boardMapper, private BoardService $boardService, - private StackMapper $stackMapper, - private CardMapper $cardMapper, - private AssignmentMapper $assignedUsersMapper, - private CommentService $commentService, + private BoardExportService $boardExportService, ) { parent::__construct(); } @@ -44,6 +37,12 @@ protected function configure() { 'User ID of the user' ) ->addOption('legacy-format', 'l') + ->addOption( + 'no-attachments', + null, + InputOption::VALUE_NONE, + 'Skip attachment contents, which keeps the export small but makes it incomplete' + ) ; } @@ -55,39 +54,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $legacyFormat = $input->getOption('legacy-format'); $this->boardService->setUserId($userId); - $boards = $this->boardService->findAll(fullDetails: false); - - $data = []; - foreach ($boards as $board) { - if ($board->getDeletedAt() > 0) { - continue; - } - - $fullBoard = $this->boardMapper->find($board->getId(), true, true); - $data[$board->getId()] = $fullBoard->jsonSerialize(); - $stacks = $this->stackMapper->findAll($board->getId()); - foreach ($stacks as $stack) { - $data[$board->getId()]['stacks'][$stack->getId()] = $stack->jsonSerialize(); - $cards = $this->cardMapper->findAllByStack($stack->getId()); - foreach ($cards as $card) { - if ($card->getDeletedAt() > 0) { - continue; - } - $fullCard = $this->cardMapper->find($card->getId()); - - $assignedUsers = $this->assignedUsersMapper->findAll($card->getId()); - $fullCard->setAssignedUsers($assignedUsers); + $this->boardExportService->setUserId($userId); - $cardDetails = new CardDetails($fullCard, $fullBoard); - $comments = $this->commentService->list($card->getId()); - $cardDetails->setCommentsCount(count($comments->getData())); + $options = new BoardExportOptions( + includeAttachments: !$input->getOption('no-attachments'), + ); + $data = $this->boardExportService->exportBoards( + $this->boardService->findAll(fullDetails: false), + $options, + ); - $cardJson = $cardDetails->jsonSerialize(); - $cardJson['comments'] = $comments->getData(); - $data[$board->getId()]['stacks'][$stack->getId()]['cards'][] = $cardJson; - } - } - } $output->writeln(json_encode( $legacyFormat ? $data : [ 'version' => $this->appManager->getAppVersion('deck'), diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 5c43ab7b12..0d28753006 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -10,9 +10,12 @@ use OCA\Deck\Db\Acl; use OCA\Deck\Db\Board; use OCA\Deck\NoPermissionException; +use OCA\Deck\Service\BoardExportOptions; +use OCA\Deck\Service\BoardExportService; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\ExternalBoardService; use OCA\Deck\Service\Importer\BoardImportService; +use OCA\Deck\Service\Importer\ImportOptions; use OCA\Deck\Service\PermissionService; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; @@ -26,6 +29,7 @@ public function __construct( $appName, IRequest $request, private BoardService $boardService, + private BoardExportService $boardExportService, private ExternalBoardService $externalBoardService, private PermissionService $permissionService, private BoardImportService $boardImportService, @@ -131,14 +135,21 @@ public function transferOwner(int $boardId, string $newOwner): DataResponse { } /** - * @NoAdminRequired - * @param $boardId - * @return Board + * Export a board with everything needed to restore it: archived cards, + * completion state, all date fields, comments and attachment contents. + * * @throws \OCP\AppFramework\Db\DoesNotExistException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException */ - public function export($boardId) { - return $this->boardService->export($boardId); + #[NoAdminRequired] + public function export(int $boardId, bool $archivedCards = true, bool $comments = true, bool $attachments = true): DataResponse { + $options = new BoardExportOptions( + includeArchivedCards: $archivedCards, + includeComments: $comments, + includeAttachments: $attachments, + ); + + return new DataResponse($this->boardExportService->exportBoard($boardId, $options)); } /** @@ -184,6 +195,7 @@ public function import(): DataResponse { $config = new \stdClass(); $config->owner = $this->userId; $this->boardImportService->setConfigInstance($config); + $this->boardImportService->setOptions(ImportOptions::fromArray($this->request->getParams())); $this->boardImportService->setData(json_decode($fileContent)); $this->boardImportService->import(); $importedBoard = $this->boardImportService->getBoard(); diff --git a/lib/Db/AttachmentMapper.php b/lib/Db/AttachmentMapper.php index d96a4f93af..08e79c8a6c 100644 --- a/lib/Db/AttachmentMapper.php +++ b/lib/Db/AttachmentMapper.php @@ -91,6 +91,36 @@ public function findCountByCardIds(array $cardIds): array { return $counts; } + /** + * Fetch the attachments of many cards at once, keyed by card id. + * + * @param int[] $cardIds + * @return array> + * @throws \OCP\DB\Exception + */ + public function findAllForCards(array $cardIds): array { + if (empty($cardIds)) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('card_id', $qb->createParameter('cardIds'))) + ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + + $attachmentsByCard = []; + $attachments = $this->chunkQuery($cardIds, function (array $ids) use ($qb) { + $qb->setParameter('cardIds', $ids, IQueryBuilder::PARAM_INT_ARRAY); + return $this->findEntities($qb); + }); + foreach ($attachments as $attachment) { + $attachmentsByCard[$attachment->getCardId()][] = $attachment; + } + + return $attachmentsByCard; + } + /** * @return Entity[] * @throws \OCP\DB\Exception diff --git a/lib/Service/BoardExportOptions.php b/lib/Service/BoardExportOptions.php new file mode 100644 index 0000000000..42f8cdc098 --- /dev/null +++ b/lib/Service/BoardExportOptions.php @@ -0,0 +1,26 @@ +permissionService->setUserId($userId); + } + + /** + * Export a single board including everything needed to restore it. + * + * @return array + * @throws \OCA\Deck\NoPermissionException + */ + public function exportBoard(int $boardId, BoardExportOptions $options = new BoardExportOptions()): array { + $this->permissionService->checkPermission($this->boardMapper, $boardId, Acl::PERMISSION_READ); + + $board = $this->boardMapper->find($boardId, true, true); + $this->boardMapper->mapOwner($board); + foreach ($board->getAcl() ?? [] as &$acl) { + $this->boardMapper->mapAcl($acl); + } + + return $this->serializeBoard($board, $options); + } + + /** + * Export a set of boards, keyed by board id. Boards in the trash are + * skipped, they are not part of the state a user wants to restore. + * + * @param Board[] $boards + * @return array> + */ + public function exportBoards(array $boards, BoardExportOptions $options = new BoardExportOptions()): array { + $exported = []; + foreach ($boards as $board) { + if ($board->getDeletedAt() > 0) { + continue; + } + $exported[$board->getId()] = $this->exportBoard($board->getId(), $options); + } + + return $exported; + } + + /** + * @return array + */ + private function serializeBoard(Board $board, BoardExportOptions $options): array { + $data = $board->jsonSerialize(); + // Permissions describe the requesting user, not the board itself + unset($data['permissions'], $data['activeSessions']); + + $stacks = $this->stackMapper->findAll($board->getId()); + $data['stacks'] = $this->serializeStacks($board, $stacks, $options); + + return $data; + } + + /** + * @param Stack[] $stacks + * @return list> + */ + private function serializeStacks(Board $board, array $stacks, BoardExportOptions $options): array { + if (count($stacks) === 0) { + return []; + } + + $stackIds = array_map(static fn (Stack $stack) => $stack->getId(), $stacks); + $cardsByStack = $this->collectCards($stackIds, $options); + + $allCards = array_merge(...array_values($cardsByStack)) ?: []; + $cardIds = array_map(static fn (Card $card) => $card->getId(), $allCards); + $labelsByCard = $this->collectLabels($cardIds); + $assignmentsByCard = $this->collectAssignments($cardIds); + // The attachment count is reported even when the contents are left out, + // otherwise a CSV export would show every card as having none + $attachmentsByCard = $options->includeAttachments ? $this->collectAttachments($cardIds) : []; + $attachmentCounts = $options->includeAttachments + ? array_map(static fn (array $attachments) => count($attachments), $attachmentsByCard) + : $this->collectAttachmentCounts($cardIds); + $dependenciesByCard = $this->cardMapper->findDependenciesForCards($cardIds); + + $serialized = []; + foreach ($stacks as $stack) { + $stackData = $stack->jsonSerialize(); + $stackData['cards'] = array_map( + fn (Card $card) => $this->serializeCard( + $card, + $board, + $labelsByCard[$card->getId()] ?? [], + $assignmentsByCard[$card->getId()] ?? [], + $attachmentsByCard[$card->getId()] ?? [], + $attachmentCounts[$card->getId()] ?? 0, + $dependenciesByCard[$card->getId()] ?? [], + $options, + ), + $cardsByStack[$stack->getId()] ?? [], + ); + $serialized[] = $stackData; + } + + return $serialized; + } + + /** + * @param int[] $stackIds + * @return array> + */ + private function collectCards(array $stackIds, BoardExportOptions $options): array { + $cardsByStack = array_fill_keys($stackIds, []); + + foreach ($this->cardMapper->findAllForStacks($stackIds) as $stackId => $cards) { + $cardsByStack[$stackId] = $cards ?? []; + } + + if (!$options->includeArchivedCards) { + return $cardsByStack; + } + + foreach ($this->cardMapper->findAllArchivedForStacks($stackIds) as $stackId => $cards) { + if (count($cards) > 0) { + $cardsByStack[$stackId] = array_merge($cardsByStack[$stackId] ?? [], $cards); + } + } + + return $cardsByStack; + } + + /** + * @param int[] $cardIds + * @return array> + */ + private function collectLabels(array $cardIds): array { + if (count($cardIds) === 0) { + return []; + } + + $labelsByCard = []; + foreach ($this->labelMapper->findAssignedLabelsForCards($cardIds) as $label) { + $labelsByCard[$label->getCardId()][] = $label; + } + + return $labelsByCard; + } + + /** + * @param int[] $cardIds + * @return array> + */ + private function collectAssignments(array $cardIds): array { + if (count($cardIds) === 0) { + return []; + } + + $assignmentsByCard = []; + foreach ($this->assignmentMapper->findIn($cardIds) as $assignment) { + $assignmentsByCard[$assignment->getCardId()][] = $assignment; + } + + return $assignmentsByCard; + } + + /** + * @param Label[] $labels + * @param Assignment[] $assignments + * @param list> $attachments + * @param int $attachmentCount how many the card has, which can be more than + * the number of exported payloads + * @param int[] $dependentCards ids of the cards this one depends on + * @return array + */ + private function serializeCard(Card $card, Board $board, array $labels, array $assignments, array $attachments, int $attachmentCount, array $dependentCards, BoardExportOptions $options): array { + $card->setLabels($labels); + $card->setAssignedUsers($assignments); + $card->setDependentCards($dependentCards); + + $comments = $options->includeComments ? $this->serializeComments($card->getId()) : []; + $card->setCommentsCount(count($comments)); + $card->setAttachmentCount($attachmentCount); + + $data = (new CardDetails($card, $board))->jsonSerialize(); + $data['comments'] = $comments; + $data['attachments'] = $attachments; + + return $data; + } + + /** + * @return list> + */ + private function serializeComments(int $cardId): array { + $comments = iterator_to_array($this->commentsManager->getForObject( + Application::COMMENT_ENTITY_TYPE, + (string)$cardId, + )); + usort($comments, static fn (IComment $first, IComment $second) => ((int)$first->getId()) <=> ((int)$second->getId())); + + return array_map(static fn (IComment $comment) => [ + 'id' => $comment->getId(), + 'parentId' => $comment->getParentId(), + 'actorType' => $comment->getActorType(), + 'actorId' => $comment->getActorId(), + 'message' => $comment->getMessage(), + 'creationDateTime' => $comment->getCreationDateTime()->format(\DateTime::ATOM), + 'objectType' => $comment->getObjectType(), + 'objectId' => $comment->getObjectId(), + 'verb' => $comment->getVerb(), + ], $comments); + } + + /** + * Attachments live in two places: `deck_file` attachments are stored in the + * app data folder, `file` attachments are shares of a Files app node. Both + * are exported with their content so an import can recreate them. + * + * @param int[] $cardIds + * @return array>> + */ + private function collectAttachments(array $cardIds): array { + if (count($cardIds) === 0) { + return []; + } + + $attachmentsByCard = []; + foreach ($this->collectDeckFileAttachments($cardIds) as $cardId => $attachments) { + foreach ($attachments as $attachment) { + $serialized = $this->serializeDeckFileAttachment($attachment); + if ($serialized !== null) { + $attachmentsByCard[$cardId][] = $serialized; + } + } + } + + $shared = $this->shareFileAttachmentExportService->exportAttachmentsForCards( + $cardIds, + $this->permissionService->getUserId() ?? '', + ); + foreach ($shared as $cardId => $attachments) { + $attachmentsByCard[$cardId] = array_merge($attachmentsByCard[$cardId] ?? [], $attachments); + } + + return $attachmentsByCard; + } + + /** + * How many attachments each card has, without reading a single file. + * + * @param int[] $cardIds + * @return array + */ + private function collectAttachmentCounts(array $cardIds): array { + if (count($cardIds) === 0) { + return []; + } + + $counts = []; + foreach ($this->collectDeckFileAttachments($cardIds) as $cardId => $attachments) { + $counts[$cardId] = count($attachments); + } + foreach ($this->shareFileAttachmentExportService->countAttachmentsForCards($cardIds) as $cardId => $count) { + $counts[$cardId] = ($counts[$cardId] ?? 0) + $count; + } + + return $counts; + } + + /** + * @param int[] $cardIds + * @return array> + */ + private function collectDeckFileAttachments(array $cardIds): array { + $byCard = []; + foreach ($this->attachmentMapper->findAllForCards($cardIds) as $cardId => $attachments) { + foreach ($attachments as $attachment) { + // `file` attachments are Files app shares, they have no row here + if ($attachment instanceof Attachment && $attachment->getType() === 'deck_file') { + $byCard[$cardId][] = $attachment; + } + } + } + + return $byCard; + } + + /** + * @return array|null + */ + private function serializeDeckFileAttachment(Attachment $attachment): ?array { + try { + $content = $this->fileService->getFolder($attachment) + ->getFile($attachment->getData()) + ->getContent(); + } catch (\Throwable $e) { + $this->logger->info('Could not read attachment content for export', ['exception' => $e]); + return null; + } + + return [ + 'type' => 'file', + 'data' => (string)$attachment->getData(), + 'createdBy' => (string)$attachment->getCreatedBy(), + 'createdAt' => (int)$attachment->getCreatedAt(), + 'lastModified' => (int)$attachment->getLastModified(), + 'contentBase64' => base64_encode($content), + ]; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 1610352ae3..6317075a60 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -657,20 +657,6 @@ public function transferOwnership(string $owner, string $newOwner, bool $changeC } } - /** - * @throws DoesNotExistException - * @throws NoPermissionException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException - */ - public function export(int $id): Board { - $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); - $board = $this->boardMapper->find($id); - $this->enrichWithCards($board); - $this->enrichWithLabels($board); - - return $board; - } - /** * @param Board[] $boards * @return Board[] @@ -829,40 +815,4 @@ private function clearBoardFromCache(Board $board): void { unset($this->boardsCachePartial[$boardId]); } - private function enrichWithCards(Board $board): void { - $stacks = $this->stackMapper->findAll($board->getId()); - if (\count($stacks) === 0) { - return; - } - - $stackIds = array_map(fn (Stack $stack) => $stack->getId(), $stacks); - - // Fetch all active cards for all stacks in one query - $cardsByStack = $this->cardMapper->findAllForStacks($stackIds); - - $allCards = array_merge(...array_values(array_filter($cardsByStack))); - $allCardIds = array_map(fn (Card $card) => $card->getId(), $allCards); - - // Batch-fetch labels and assigned users for all cards - $labelsByCard = []; - foreach ($this->labelMapper->findAssignedLabelsForCards($allCardIds) as $label) { - $labelsByCard[$label->getCardId()][] = $label; - } - $usersByCard = []; - foreach ($this->assignedUsersMapper->findIn($allCardIds) as $assignment) { - $usersByCard[$assignment->getCardId()][] = $assignment; - } - - foreach ($stacks as $stack) { - $fullCards = []; - foreach ($cardsByStack[$stack->getId()] ?? [] as $card) { - $card->setLabels($labelsByCard[$card->getId()] ?? []); - $card->setAssignedUsers($usersByCard[$card->getId()] ?? []); - $fullCards[] = $card; - } - $stack->setCards($fullCards); - } - - $board->setStacks($stacks); - } } diff --git a/lib/Service/Importer/ABoardImportService.php b/lib/Service/Importer/ABoardImportService.php index b0b1576d6e..eaeea07dc3 100644 --- a/lib/Service/Importer/ABoardImportService.php +++ b/lib/Service/Importer/ABoardImportService.php @@ -69,6 +69,16 @@ abstract public function getCardAssignments(): array; */ abstract public function getCardLabelAssignment(): array; + /** + * Dependencies between cards, keyed by card id. Sources that have no such + * concept simply import none. + * + * @return array + */ + public function getCardDependencies(): array { + return []; + } + /** * @return array> */ diff --git a/lib/Service/Importer/BoardImportCommandService.php b/lib/Service/Importer/BoardImportCommandService.php index ddaef57796..057fcc4384 100644 --- a/lib/Service/Importer/BoardImportCommandService.php +++ b/lib/Service/Importer/BoardImportCommandService.php @@ -204,22 +204,41 @@ public function import(): void { try { $this->reset(); $this->setData($board); + $options = $this->getOptions(); $this->getOutput()->writeln('Importing board "' . $board->title . '".'); $this->importBoard(); - $this->getOutput()->writeln('Assign users to board...'); - $this->importAcl(); - $this->getOutput()->writeln('Importing labels...'); - $this->importLabels(); + if ($options->importSharing) { + $this->getOutput()->writeln('Assign users to board...'); + $this->importAcl(); + } + if ($options->importLabels) { + $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(); + if ($options->importCards) { + $this->getOutput()->writeln('Importing cards...'); + $this->importCards(); + if ($options->importAttachments) { + $this->getOutput()->writeln('Importing attachments...'); + $this->getImportSystem()->importAttachments(); + } + if ($options->importLabels) { + $this->getOutput()->writeln('Assign cards to labels...'); + $this->assignCardsToLabels(); + } + if ($options->importComments) { + $this->getOutput()->writeln('Importing comments...'); + $this->importComments(); + } + if ($options->importAssignments) { + $this->getOutput()->writeln('Importing participants...'); + $this->importCardAssignments(); + } + $this->getOutput()->writeln('Importing card dependencies...'); + $this->importCardDependencies(); + } $this->getOutput()->writeln('Finished board import of "' . $this->getBoard()->getTitle() . '"'); } catch (\Exception $e) { $this->output->writeln('Import failed for board ' . $board->title . ': ' . $e->getMessage() . ''); diff --git a/lib/Service/Importer/BoardImportService.php b/lib/Service/Importer/BoardImportService.php index 2bb17dee55..989bd42da0 100644 --- a/lib/Service/Importer/BoardImportService.php +++ b/lib/Service/Importer/BoardImportService.php @@ -56,6 +56,7 @@ class BoardImportService { */ private $data; private Board $board; + private ImportOptions $options; /** @var callable[] */ private array $errorCollectors = []; @@ -80,6 +81,7 @@ public function __construct( $this->disableCommentsEvents(); $this->config = new \stdClass(); + $this->options = new ImportOptions(); } public function registerErrorCollector(callable $errorCollector): void { @@ -90,6 +92,15 @@ public function registerOutputCollector(callable $outputCollector): void { $this->outputCollectors[] = $outputCollector; } + public function setOptions(ImportOptions $options): self { + $this->options = $options; + return $this; + } + + public function getOptions(): ImportOptions { + return $this->options; + } + private function addError(string $message, $exception): void { $message .= ' (on board ' . $this->getBoard()->getTitle() . ')'; foreach ($this->errorCollectors as $errorCollector) { @@ -123,14 +134,29 @@ public function import(): void { $this->reset(); $this->setData($board); $this->importBoard(); - $this->importAcl(); - $this->importLabels(); + if ($this->options->importSharing) { + $this->importAcl(); + } + if ($this->options->importLabels) { + $this->importLabels(); + } $this->importStacks(); - $this->importCards(); - $this->getImportSystem()->importAttachments(); - $this->assignCardsToLabels(); - $this->importComments(); - $this->importCardAssignments(); + if ($this->options->importCards) { + $this->importCards(); + if ($this->options->importAttachments) { + $this->getImportSystem()->importAttachments(); + } + if ($this->options->importLabels) { + $this->assignCardsToLabels(); + } + if ($this->options->importComments) { + $this->importComments(); + } + if ($this->options->importAssignments) { + $this->importCardAssignments(); + } + $this->importCardDependencies(); + } } catch (\Throwable $th) { $this->logger->error('Failed to import board', ['exception' => $th]); throw new BadRequestException($th->getMessage()); @@ -297,6 +323,22 @@ public function importCards(): void { } } + /** + * Recreate the dependencies between cards. Runs after every card of the + * board exists, because a card can depend on one in a later list. + */ + public function importCardDependencies(): void { + foreach ($this->getImportSystem()->getCardDependencies() as $cardId => $dependentCardIds) { + foreach ($dependentCardIds as $dependentCardId) { + try { + $this->cardMapper->addDependency($cardId, $dependentCardId); + } catch (\Exception $e) { + $this->addError('Failed to import card dependency ' . $cardId . ' -> ' . $dependentCardId, $e); + } + } + } + } + public function assignCardToLabel(int $cardId, int $labelId): self { $this->cardMapper->assignLabel( $cardId, diff --git a/lib/Service/Importer/ImportOptions.php b/lib/Service/Importer/ImportOptions.php new file mode 100644 index 0000000000..248a9490f4 --- /dev/null +++ b/lib/Service/Importer/ImportOptions.php @@ -0,0 +1,59 @@ + $values + */ + public static function fromArray(array $values): self { + $flag = static function (string $key) use ($values): bool { + if (!array_key_exists($key, $values)) { + return true; + } + return filter_var($values[$key], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? true; + }; + + return new self( + importCards: $flag('importCards'), + importArchivedCards: $flag('importArchivedCards'), + importDoneState: $flag('importDoneState'), + importDueDates: $flag('importDueDates'), + importLabels: $flag('importLabels'), + importAssignments: $flag('importAssignments'), + importComments: $flag('importComments'), + importAttachments: $flag('importAttachments'), + importSharing: $flag('importSharing'), + ); + } +} diff --git a/lib/Service/Importer/Systems/DeckJsonService.php b/lib/Service/Importer/Systems/DeckJsonService.php index 99dcb36bac..0c282cde52 100644 --- a/lib/Service/Importer/Systems/DeckJsonService.php +++ b/lib/Service/Importer/Systems/DeckJsonService.php @@ -100,10 +100,24 @@ public function getCardAssignments(): array { continue; } foreach ($sourceCard->assignedUsers as $idMember) { + // `participant` is an object when the export could resolve the + // user/group/circle and a plain uid string when it could not + $participant = $idMember->participant ?? null; + if (is_object($participant)) { + $participantId = $participant->uid ?? $participant->primaryKey ?? null; + $type = $participant->type ?? $idMember->type ?? Assignment::TYPE_USER; + } else { + $participantId = $participant; + $type = $idMember->type ?? Assignment::TYPE_USER; + } + if ($participantId === null) { + continue; + } + $assignment = new Assignment(); $assignment->setCardId($this->cards[$sourceCard->id]->getId()); - $assignment->setParticipant($this->mapMember($idMember->participant->uid ?? $idMember->participant)); - $assignment->setType($idMember->participant->type); + $assignment->setParticipant($this->mapMember($participantId)); + $assignment->setType((int)$type); $assignments[$sourceCard->id][] = $assignment; } } @@ -257,19 +271,30 @@ public function getLabels(): array { * @return Stack[] */ public function getStacks(): array { + $options = $this->getImportService()->getOptions(); $return = []; + $doneColumnTaken = false; foreach ($this->getImportService()->getData()->stacks as $index => $source) { if ($source->title) { + // A board can only have a single done column, ignore any further + // ones a hand-crafted import file might contain + $isDoneColumn = !$doneColumnTaken && !empty($source->isDoneColumn); + $doneColumnTaken = $doneColumnTaken || $isDoneColumn; + $stack = new Stack(); $stack->setTitle($source->title); $stack->setBoardId($this->getImportService()->getBoard()->getId()); $stack->setOrder($source->order); $stack->setLastModified($source->lastModified); + $stack->setIsDoneColumn($isDoneColumn); $return[$source->id] = $stack; } if (isset($source->cards)) { foreach ($source->cards as $card) { + if (!$options->importArchivedCards && !empty($card->archived)) { + continue; + } $card->stackId = $source->id; $this->tmpCards[] = $card; } @@ -282,6 +307,7 @@ public function getStacks(): array { * @return Card[] */ public function getCards(): array { + $options = $this->getImportService()->getOptions(); $cards = []; foreach ($this->tmpCards as $cardSource) { $card = new Card(); @@ -289,21 +315,68 @@ public function getCards(): array { $card->setLastModified($cardSource->lastModified); $card->setLastEditor($cardSource->lastEditor); $card->setCreatedAt($cardSource->createdAt); - $card->setArchived($cardSource->archived); + $card->setArchived($options->importArchivedCards && !empty($cardSource->archived)); $card->setDescription($cardSource->description); + $card->setColor($cardSource->color ?? null); $card->setStackId($this->stacks[$cardSource->stackId]->getId()); - $card->setType('plain'); + $card->setType($cardSource->type ?? 'plain'); $card->setOrder($cardSource->order); $boardOwner = $this->getBoard()->getOwner(); $card->setOwner($this->mapOwner(is_string($boardOwner) ? $boardOwner : $boardOwner->getUID())); - $card->setDuedate($cardSource->duedate ? \DateTime::createFromFormat(\DateTime::ATOM, $cardSource->duedate) : null); - $card->setStartdate(isset($cardSource->startdate) && $cardSource->startdate !== null ? \DateTime::createFromFormat(\DateTime::ATOM, $cardSource->startdate) : null); - $card->setDone(isset($cardSource->done) && $cardSource->done !== null ? \DateTime::createFromFormat(\DateTime::ATOM, $cardSource->done) : null); + $card->setDuedate($options->importDueDates ? $this->parseDate($cardSource->duedate ?? null) : null); + $card->setStartdate($options->importDueDates ? $this->parseDate($cardSource->startdate ?? null) : null); + $card->setDone($options->importDoneState ? $this->parseDate($cardSource->done ?? null) : null); $cards[$cardSource->id] = $card; } return $cards; } + /** + * Card dependencies reference other cards by their id in the export, so they + * can only be resolved once every card of the board has been created. + * + * A dependency pointing at a card that is not part of this import - one on + * another board, or one skipped because archived cards were deselected - is + * dropped rather than guessed at. + * + * @return array new dependent card ids, by new card id + */ + public function getCardDependencies(): array { + $dependencies = []; + foreach ($this->tmpCards as $sourceCard) { + if (!property_exists($sourceCard, 'dependentCards') || !is_iterable($sourceCard->dependentCards ?? null)) { + continue; + } + if (!isset($this->cards[$sourceCard->id])) { + continue; + } + + $cardId = $this->cards[$sourceCard->id]->getId(); + foreach ($sourceCard->dependentCards as $sourceDependentId) { + if (!isset($this->cards[$sourceDependentId])) { + continue; + } + $dependencies[$cardId][] = $this->cards[$sourceDependentId]->getId(); + } + } + + return $dependencies; + } + + /** + * Dates are exported as ISO 8601 including the offset, so the value keeps + * pointing at the same instant no matter which timezone imports it. + */ + private function parseDate(?string $value): ?\DateTime { + if ($value === null || $value === '') { + return null; + } + + $date = \DateTime::createFromFormat(\DateTime::ATOM, $value); + + return $date === false ? null : $date; + } + /** * @return Acl[] */ diff --git a/lib/Service/ShareFileAttachmentExportService.php b/lib/Service/ShareFileAttachmentExportService.php index 2c68a39da1..262e654220 100644 --- a/lib/Service/ShareFileAttachmentExportService.php +++ b/lib/Service/ShareFileAttachmentExportService.php @@ -9,6 +9,7 @@ namespace OCA\Deck\Service; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\IRootFolder; use OCP\IDBConnection; @@ -23,11 +24,22 @@ public function __construct( * @return array> */ public function exportCardAttachments(int $cardId, string $fallbackUserId): array { + return $this->exportAttachmentsForCards([$cardId], $fallbackUserId)[$cardId] ?? []; + } + + /** + * Export the file shares of many cards at once, keyed by card id, so that + * exporting a whole board does not run one query per card. + * + * @param int[] $cardIds + * @return array>> + */ + public function exportAttachmentsForCards(array $cardIds, string $fallbackUserId): array { $formattedAttachments = []; - foreach ($this->getShareFileAttachments($cardId) as $share) { + foreach ($this->getShareFileAttachments($cardIds) as $share) { $shareAttachment = $this->serializeShareAttachment($share, $fallbackUserId); if ($shareAttachment !== null) { - $formattedAttachments[] = $shareAttachment; + $formattedAttachments[(int)$share['share_with']][] = $shareAttachment; } } @@ -35,14 +47,55 @@ public function exportCardAttachments(int $cardId, string $fallbackUserId): arra } /** + * Count the file shares of many cards without reading any file contents, + * so an export that leaves attachments out can still report how many a + * card has. + * + * @param int[] $cardIds + * @return array + */ + public function countAttachmentsForCards(array $cardIds): array { + if (empty($cardIds)) { + return []; + } + + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select('share_with') + ->selectAlias($qb->func()->count('id'), 'attachment_count') + ->from('share') + ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(12))) + ->andWhere($qb->expr()->in('share_with', $qb->createNamedParameter( + array_map('strval', $cardIds), + IQueryBuilder::PARAM_STR_ARRAY, + ))) + ->andWhere($qb->expr()->eq('item_type', $qb->createNamedParameter('file'))) + ->groupBy('share_with'); + + $counts = []; + foreach ($qb->executeQuery()->fetchAllAssociative() as $row) { + $counts[(int)$row['share_with']] = (int)$row['attachment_count']; + } + + return $counts; + } + + /** + * @param int[] $cardIds * @return array> */ - private function getShareFileAttachments(int $cardId): array { + private function getShareFileAttachments(array $cardIds): array { + if (empty($cardIds)) { + return []; + } + $qb = $this->dbConnection->getQueryBuilder(); - $qb->select('id', 'uid_owner', 'uid_initiator', 'file_source', 'stime') + $qb->select('id', 'uid_owner', 'uid_initiator', 'file_source', 'stime', 'share_with') ->from('share') ->where($qb->expr()->eq('share_type', $qb->createNamedParameter(12))) - ->andWhere($qb->expr()->eq('share_with', $qb->createNamedParameter((string)$cardId))) + ->andWhere($qb->expr()->in('share_with', $qb->createNamedParameter( + array_map('strval', $cardIds), + IQueryBuilder::PARAM_STR_ARRAY, + ))) ->andWhere($qb->expr()->eq('item_type', $qb->createNamedParameter('file'))); return $qb->executeQuery()->fetchAllAssociative(); } diff --git a/lib/UserMigration/DeckMigrator.php b/lib/UserMigration/DeckMigrator.php index 7b4caf2545..e44a38cd61 100644 --- a/lib/UserMigration/DeckMigrator.php +++ b/lib/UserMigration/DeckMigrator.php @@ -9,20 +9,11 @@ namespace OCA\Deck\UserMigration; -use OCA\Deck\AppInfo\Application; -use OCA\Deck\Db\AclMapper; -use OCA\Deck\Db\AssignmentMapper; -use OCA\Deck\Db\AttachmentMapper; use OCA\Deck\Db\BoardMapper; -use OCA\Deck\Db\CardMapper; -use OCA\Deck\Db\LabelMapper; -use OCA\Deck\Db\StackMapper; +use OCA\Deck\Service\BoardExportService; use OCA\Deck\Service\BoardService; use OCA\Deck\Service\Importer\BoardImportService; use OCA\Deck\Service\PermissionService; -use OCA\Deck\Service\ShareFileAttachmentExportService; -use OCP\Comments\ICommentsManager; -use OCP\Files\IAppData; use OCP\IL10N; use OCP\IUser; use OCP\UserMigration\IExportDestination; @@ -43,15 +34,7 @@ class DeckMigrator implements IMigrator, ISizeEstimationMigrator { public function __construct( protected IL10N $l10n, protected BoardMapper $boardMapper, - protected StackMapper $stackMapper, - protected CardMapper $cardMapper, - protected LabelMapper $labelMapper, - protected AclMapper $aclMapper, - protected AssignmentMapper $assignmentMapper, - protected AttachmentMapper $attachmentMapper, - protected ICommentsManager $commentsManager, - protected IAppData $appData, - protected ShareFileAttachmentExportService $shareFileAttachmentExportService, + protected BoardExportService $boardExportService, protected BoardService $boardService, protected BoardImportService $boardImportService, protected PermissionService $permissionService, @@ -121,97 +104,11 @@ public function import( } private function buildExportData(string $uid): array { - $boards = $this->boardMapper->findAllByUser($uid); - $exportData = ['boards' => []]; - - foreach ($boards as $board) { - // skip if the board is deleted (to align with the export service) - if ($board->getDeletedAt() > 0) { - continue; - } - $boardWithStacksAndCards = $this->boardService->export($board->getId()); - $this->appendArchivedCards($boardWithStacksAndCards); - $exportData['boards'][] = $this->serializeBoard($boardWithStacksAndCards, $uid); - } - - return $exportData; - } - - private function serializeBoard(object $board, string $uid): array { - $boardData = $board->jsonSerialize(); - $serializedStacks = []; - foreach ($board->getStacks() ?? [] as $stack) { - $stackData = $stack->jsonSerialize(); - $serializedCards = []; - foreach ($stack->getCards() ?? [] as $card) { - $serializedCards[] = $this->serializeCard($card, $uid); - } - $stackData['cards'] = $serializedCards; - $serializedStacks[] = $stackData; - } - $boardData['stacks'] = $serializedStacks; - - return $boardData; - } - - private function appendArchivedCards(object $board): void { - $stacks = $board->getStacks() ?? []; - if (count($stacks) === 0) { - return; - } - - $stackIds = array_map(static fn ($stack) => $stack->getId(), $stacks); - $archivedCardsByStack = $this->cardMapper->findAllArchivedForStacks($stackIds); - - foreach ($stacks as $stack) { - $activeCards = $stack->getCards() ?? []; - $archivedCards = $archivedCardsByStack[$stack->getId()] ?? []; - if (count($archivedCards) === 0) { - continue; - } - $stack->setCards(array_merge($activeCards, $archivedCards)); - } - } - - private function serializeCard(object $card, string $uid): array { - $cardId = $card->getId(); - - $cardData = $card->jsonSerialize(); - $cardData['comments'] = (isset($cardData['comments']) && is_array($cardData['comments']) && $cardData['comments'] !== []) - ? $cardData['comments'] - : $this->serializeCardComments($cardId); - $cardData['attachments'] = (isset($cardData['attachments']) && is_array($cardData['attachments']) && $cardData['attachments'] !== []) - ? $cardData['attachments'] - : $this->shareFileAttachmentExportService->exportCardAttachments($cardId, $uid); - - return $cardData; - } - - private function serializeCardComments(int $cardId): array { - $comments = iterator_to_array($this->commentsManager->getForObject( - Application::COMMENT_ENTITY_TYPE, - (string)$cardId - )); - usort($comments, static function ($firstComment, $secondComment): int { - return ((int)$firstComment->getId()) <=> ((int)$secondComment->getId()); - }); - - $formattedComments = []; - foreach ($comments as $comment) { - $formattedComments[] = [ - 'id' => $comment->getId(), - 'parentId' => $comment->getParentId(), - 'actorType' => $comment->getActorType(), - 'actorId' => $comment->getActorId(), - 'message' => $comment->getMessage(), - 'creationDateTime' => $comment->getCreationDateTime()->format(\DateTime::ATOM), - 'objectType' => $comment->getObjectType(), - 'objectId' => $comment->getObjectId(), - 'verb' => $comment->getVerb(), - ]; - } - - return $formattedComments; + return [ + 'boards' => array_values($this->boardExportService->exportBoards( + $this->boardMapper->findAllByUser($uid), + )), + ]; } private function shouldImport(IImportSource $importSource): bool { diff --git a/package.json b/package.json index 20f9f60140..98641d8a1e 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ }, "transform": { "^.+\\.js$": "/node_modules/babel-jest", - ".*\\.(vue)$": "/node_modules/vue-jest" + ".*\\.(vue)$": "/node_modules/@vue/vue2-jest" }, "snapshotSerializers": [ "/node_modules/jest-serializer-vue" diff --git a/src/components/navigation/AppNavigationBoard.vue b/src/components/navigation/AppNavigationBoard.vue index 062ce9d1c0..aeae17bcd6 100644 --- a/src/components/navigation/AppNavigationBoard.vue +++ b/src/components/navigation/AppNavigationBoard.vue @@ -454,11 +454,17 @@ export default { actionExport() { this.exportModalOpen = true }, - async onExportBoard(format) { + async onExportBoard(format, options) { this.exportModalOpen = false const loadingToast = showLoading(t('deck', 'Exporting board...')) - await this.boardApi.exportBoard(this.board, format) - loadingToast.hideToast() + try { + await this.boardApi.exportBoard(this.board, format, options) + } catch (err) { + showError(t('deck', 'Could not export board')) + console.error(err) + } finally { + loadingToast.hideToast() + } }, onCloseExportBoard() { this.exportModalOpen = false diff --git a/src/components/navigation/AppNavigationImportBoard.vue b/src/components/navigation/AppNavigationImportBoard.vue index 7605fcbbfb..e3ccf791ea 100644 --- a/src/components/navigation/AppNavigationImportBoard.vue +++ b/src/components/navigation/AppNavigationImportBoard.vue @@ -9,7 +9,11 @@ type="file" accept="application/json" style="display: none;" - @change="doImportBoard"> + @change="onFileSelected"> + @@ -18,10 +22,11 @@ import { NcAppNavigationItem } from '@nextcloud/vue' import { showError } from '../../helpers/errors.js' import { showSuccess, showLoading } from '@nextcloud/dialogs' import { useBoardStore } from '../../stores/board.js' +import BoardImportModal from './BoardImportModal.vue' export default { name: 'AppNavigationImportBoard', - components: { NcAppNavigationItem }, + components: { NcAppNavigationItem, BoardImportModal }, props: { loading: { type: Boolean, @@ -31,6 +36,7 @@ export default { data() { return { value: '', + selectedFile: null, } }, methods: { @@ -38,17 +44,29 @@ export default { this.$refs.fileInput.value = '' this.$refs.fileInput.click() }, - async doImportBoard(event) { + onFileSelected(event) { const file = event.target.files[0] if (file) { - const loadingToast = showLoading(t('deck', 'Importing board...')) - const result = await useBoardStore().importBoard(file) - loadingToast.hideToast() - if (result?.message) { - showError(result) - } else { - showSuccess(t('deck', 'Board imported successfully')) - } + this.selectedFile = file + } + }, + cancelImport() { + this.selectedFile = null + }, + async doImportBoard(options) { + const file = this.selectedFile + this.selectedFile = null + if (!file) { + return + } + + const loadingToast = showLoading(t('deck', 'Importing board...')) + const result = await useBoardStore().importBoard(file, options) + loadingToast.hideToast() + if (result?.message) { + showError(result) + } else { + showSuccess(t('deck', 'Board imported successfully')) } }, }, diff --git a/src/components/navigation/BoardExportModal.vue b/src/components/navigation/BoardExportModal.vue index 10a3544178..003267a67e 100644 --- a/src/components/navigation/BoardExportModal.vue +++ b/src/components/navigation/BoardExportModal.vue @@ -21,6 +21,25 @@

{{ t('deck', 'Note: Only the JSON format is supported for importing back into the Deck app.') }}

+ +
+ {{ t('deck', 'Content to export') }} + + {{ t('deck', 'Archived cards') }} + + + +