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/User_documentation_en.md b/docs/User_documentation_en.md index e0b3b56aae..9d6b57b304 100644 --- a/docs/User_documentation_en.md +++ b/docs/User_documentation_en.md @@ -21,7 +21,7 @@ Overall, Deck is easy to use. You can create boards, add users, share the Deck, 5. [Archive old tasks](#5-archive-old-tasks) 6. [Manage your board](#6-manage-your-board) 7. [Sharing boards](#7-sharing-boards) -8. [Import boards](#8-import-boards) +8. [Export and import boards](#8-export-and-import-boards) 9. [Trello interoperability](#9-trello-interoperability) 10. [Search](#10-search) 11. [New owner for the deck entities](#11-new-owner-for-the-deck-entities) @@ -95,11 +95,43 @@ If a user is member of one or several group(s) or circle(s) a board is shared wi 1. If a user is namely specified in a board's acl, (s)he gets the permission assigned to her/him; 2. Else, if a user is member of one or several circle(s) or group(s), (s)he gets the permission when at least one circle/group (s)he is member of has the permission. -### 8. Import boards +### 8. Export and import boards -Importing can be done using the API or the `occ` `deck:import` command. +#### Exporting a board -Comments with more than 1000 characters are placed as attached files to the card. +Open the board menu (**...**) and choose **Export board**. Two formats are offered: + +* **JSON** — a complete copy of the board. It contains the lists, all cards + including archived ones, their completion state and dates, tags, assigned + users, comments and the contents of file attachments. This is the format to + pick if you want to be able to import the board again later. +* **CSV** — one row per card, for opening in a spreadsheet or feeding into a + reporting tool. It cannot be imported back into Deck. + +Attachments are embedded in the JSON file, which can make it large. If you do not +need them, untick **Attachments** in the dialog — the export will then be smaller, +but it will no longer restore the board completely. **Archived cards** and +**Comments** can be left out in the same way. + +#### Importing a board + +Choose **Import board** in the left sidebar and select a JSON file that was +exported from Deck. Before the import runs, a dialog lets you pick what to +restore: cards, archived cards, completion state, due and start dates, tags, +assigned users, comments, attachments and sharing. + +The lists and the tags of a board are always created. If you untick **Cards**, +you therefore end up with an empty copy of the board, which is a convenient way +to turn an existing board into a template. + +Two things are worth knowing about an import: + +* The board is created new and belongs to you, and its cards get new IDs. + References between cards, such as dependencies, are adjusted to the new cards. + A reference to a card that was not imported is dropped. +* Attachments are placed in your own **Deck** folder in Files. + +Importing can also be done using the API or the `occ` `deck:import` command. It is possible to import from the following sources: 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/docs/import-class-diagram.md b/docs/import-class-diagram.md index 80ab0dc7fd..a208644415 100644 --- a/docs/import-class-diagram.md +++ b/docs/import-class-diagram.md @@ -6,6 +6,6 @@ Importing boards to the Deck implements the class diagram below. -> **NOTE**: When making any changes to the structure of the classes or implementing import from other sources, edit the `BoardImport.yuml` file +> **NOTE**: When making any changes to the structure of the classes or implementing import from other sources, edit the `BoardImport.yuml` file and regenerate `BoardImport.svg` from it with the [yUML extension](https://github.com/jaime-olivares/vscode-yuml). ![Screenshot](resources/BoardImport.svg) \ No newline at end of file diff --git a/docs/resources/BoardImport.svg b/docs/resources/BoardImport.svg index 167f85f1a2..9040383345 100644 --- a/docs/resources/BoardImport.svg +++ b/docs/resources/BoardImport.svg @@ -4,211 +4,350 @@ - - + + G - + A0 - - - -Classes used on -board import. -Methods just to -illustrate. + + + +Classes used on +board import. +Methods just to +illustrate. A1 - -ApiController + +ApiController A2 - -BoardImportApiController - -+import() -+getAllowedSystems() -+getConfigSchema() + +BoardImportApiController + ++import() ++getAllowedSystems() ++getConfigSchema() A1->A2 - - + + + + + +A4 + +BoardController + ++import() ++export() + + + +A1->A4 + + A3 - -BoardImportService - -+import() -+bootstrap() -+validateSystem() -#validateConfig() -#validateData() + +BoardImportService + ++import() ++bootstrap() ++validateSystem() ++setOptions(options) ++getOptions() +#validateConfig() +#validateData() A2->A3 - - -uses + + +uses - - -A7 - -TrelloApiService - -+name:string + + +A6 + +ImportOptions + ++importCards:bool ++importArchivedCards:bool ++importDoneState:bool ++importDueDates:bool ++importLabels:bool ++importAssignments:bool ++importComments:bool ++importAttachments:bool ++importSharing:bool + ++fromArray(values) - + -A3->A7 - - -uses - - - -A9 - -TrelloJsonService - -+name:string -#needValidateData:true +A3->A6 + + +uses - - -A3->A9 - - -uses + + +A11 + +DeckJsonService + ++name:string - - -A10 - - - -validateSystem is -public because is -used on Api. - - + -A3->A10 - +A3->A11 + + +uses - - -A4 - -Command + + +A13 + +TrelloApiService + ++name:string + + + +A3->A13 + + +uses + + + +A14 + +TrelloJsonService + ++name:string +#needValidateData:true + + + +A3->A14 + + +uses + + + +A15 + + + +validateSystem is +public because is +used on Api. + + + +A3->A15 + + + + +A4->A3 + + +uses A5 - -BoardImport - -+boardImportCommandService - -#configure() -#execute(input,output) + + + +The web UI import. +Builds ImportOptions +from the request so +a user can restore +only parts of a +file. - -A4->A5 - - - - - -A6 - -BoardImportCommandService - -+bootstrap() -+import() -+validateSystem() -#validateConfig() -#validateData() - - - -A5->A6 - - -uses - - -A6->A3 - - +A4->A5 + - - -A7->A3 - - -uses + + +A7 + +Command A8 - -<<abstract>> -ABoardImportService - -#needValidateData:false - -+needValidateData():bool + +BoardImport + ++boardImportCommandService + +#configure() +#execute(input,output) - + A7->A8 - - -implements + + + + + +A9 + +BoardImportCommandService + ++bootstrap() ++import() ++validateSystem() +#validateConfig() +#validateData() + + + +A8->A9 + + +uses - + A9->A3 - - -uses + + - - -A9->A8 - - -implements + + +A10 + + + +Overrides import(), +so changes to the +import steps have to +be made in both +places. - - -A11 - - - -To create an import -to another system, -create another class -similar to this. - - + + +A9->A10 + + + -A9->A11 - +A11->A3 + + +uses + + + +A12 + +<<abstract>> +ABoardImportService + +#needValidateData:false + ++needValidateData():bool ++getCardDependencies():array + + + +A11->A12 + + +implements + + + +A13->A3 + + +uses + + + +A13->A12 + + +implements + + + +A14->A3 + + +uses + + + +A14->A12 + + +implements + + + +A16 + + + +To create an import +to another system, +create another class +similar to this. + + + +A14->A16 + + + + +A17 + +ABoardImportService + + + +A18 + + + +getCardDependencies +has a default +returning an empty +list, so sources +without that concept +need not implement +it. + + + +A17->A18 + diff --git a/docs/resources/BoardImport.yuml b/docs/resources/BoardImport.yuml index 2a7fae9bbf..5ea23a65d2 100644 --- a/docs/resources/BoardImport.yuml +++ b/docs/resources/BoardImport.yuml @@ -9,18 +9,30 @@ [note: Classes used on board import. Methods just to illustrate. {bg:cornsilk}] [ApiController]<-[BoardImportApiController|+import();+getAllowedSystems();+getConfigSchema()] -[BoardImportApiController]uses-.->[BoardImportService|+import();+bootstrap();+validateSystem();#validateConfig();#validateData();] +[BoardImportApiController]uses-.->[BoardImportService|+import();+bootstrap();+validateSystem();+setOptions(options);+getOptions();#validateConfig();#validateData();] + +[ApiController]<-[BoardController|+import();+export()] +[BoardController]uses-.->[BoardImportService] +[BoardController]-[note: The web UI import. Builds ImportOptions from the request so a user can restore only parts of a file. {bg:cornsilk}] + +[BoardImportService]uses-.->[ImportOptions|+importCards:bool;+importArchivedCards:bool;+importDoneState:bool;+importDueDates:bool;+importLabels:bool;+importAssignments:bool;+importComments:bool;+importAttachments:bool;+importSharing:bool|+fromArray(values)] [Command]<-[BoardImport|+boardImportCommandService|#configure();#execute(input,output)] [BoardImport]uses-.->[BoardImportCommandService|+bootstrap();+import();+validateSystem();#validateConfig();#validateData()] [BoardImportCommandService]->[BoardImportService] +[BoardImportCommandService]-[note: Overrides import(), so changes to the import steps have to be made in both places. {bg:cornsilk}] + +[BoardImportService]uses-.->[DeckJsonService|+name:string] +[DeckJsonService]uses-.->[BoardImportService] +[DeckJsonService]implements-.-^[<> ABoardImportService|#needValidateData:false|+needValidateData():bool;+getCardDependencies():array] [BoardImportService]uses-.->[TrelloApiService|+name:string] [TrelloApiService]uses-.->[BoardImportService] -[TrelloApiService]implements-.-^[<> ABoardImportService|#needValidateData:false|+needValidateData():bool] +[TrelloApiService]implements-.-^[<> ABoardImportService] [BoardImportService]uses-.->[TrelloJsonService|+name:string;#needValidateData:true] [TrelloJsonService]uses-.->[BoardImportService] [BoardImportService]-[note: validateSystem is public because is used on Api. {bg:cornsilk}] [TrelloJsonService]-[note: To create an import to another system, create another class similar to this. {bg:cornsilk}] [TrelloJsonService]implements-.-^[<> ABoardImportService] +[ABoardImportService]-[note: getCardDependencies has a default returning an empty list, so sources without that concept need not implement it. {bg:cornsilk}] 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 2edb86a0b4..5b87cc03bd 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -708,20 +708,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[] @@ -880,40 +866,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 3ac781b125..059c7edd3e 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..5eb75ea662 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') }} + + + +