diff --git a/.eslintrc.js b/.eslintrc.js index 69b8d1e1c..891de553a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,6 +9,6 @@ module.exports = { 'jsdoc/check-param-names': ['off'], 'jsdoc/no-undefined-types': ['off'], 'jsdoc/require-property-description': ['off'], - 'import/no-named-as-default-member': ['off'] + 'import/no-named-as-default-member': ['off'], }, } diff --git a/appinfo/routes.php b/appinfo/routes.php index b641e1073..41f398e11 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -40,6 +40,7 @@ return [ ['name' => 'board#deleteAcl', 'url' => '/boards/{boardId}/acl/{aclId}', 'verb' => 'DELETE'], ['name' => 'board#clone', 'url' => '/boards/{boardId}/clone', 'verb' => 'POST'], ['name' => 'board#transferOwner', 'url' => '/boards/{boardId}/transferOwner', 'verb' => 'PUT'], + ['name' => 'board#export', 'url' => '/boards/{boardId}/export', 'verb' => 'GET'], // stacks ['name' => 'stack#index', 'url' => '/stacks/{boardId}', 'verb' => 'GET'], diff --git a/cypress.config.js b/cypress.config.js index 9a1051dbe..dab8bd653 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,17 +1,17 @@ const { defineConfig } = require('cypress') module.exports = defineConfig({ - projectId: '1s7wkc', - viewportWidth: 1280, - viewportHeight: 720, - e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - return require('./cypress/plugins/index.js')(on, config) - }, - baseUrl: 'http://nextcloud.local/index.php', - experimentalSessionAndOrigin: true, - specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', - }, + projectId: '1s7wkc', + viewportWidth: 1280, + viewportHeight: 720, + e2e: { + // We've imported your old cypress plugins here. + // You may want to clean this up later by importing these. + setupNodeEvents(on, config) { + return require('./cypress/plugins/index.js')(on, config) + }, + baseUrl: 'http://nextcloud.local/index.php', + experimentalSessionAndOrigin: true, + specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', + }, }) diff --git a/l10n/fr.json b/l10n/fr.json index 5867256ce..9a8b68ff3 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -309,4 +309,4 @@ "Failed to transfer the board for {user}" : "Échec du transfert du tableau pour {user}", "Are you sure you want to delete the board {title}? This will delete all the data of this board." : "Êtes-vous certain de vouloir supprimer le tableau {title} ? Cela supprimera l'ensemble des données de ce tableau." },"pluralForm" :"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" -} \ No newline at end of file +} diff --git a/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 0e909c305..946d98ed6 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -169,4 +169,15 @@ class BoardController extends ApiController { return new DataResponse([], HTTP::STATUS_UNAUTHORIZED); } + + /** + * @NoAdminRequired + * @param $boardId + * @return Board + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + public function export($boardId) { + return $this->boardService->export($boardId); + } } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index b63b57759..3c5f76cc7 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -85,6 +85,7 @@ class BoardService { public function __construct( BoardMapper $boardMapper, StackMapper $stackMapper, + CardMapper $cardMapper, IConfig $config, IL10N $l10n, LabelMapper $labelMapper, @@ -92,7 +93,6 @@ class BoardService { PermissionService $permissionService, NotificationHelper $notificationHelper, AssignmentMapper $assignedUsersMapper, - CardMapper $cardMapper, IUserManager $userManager, IGroupManager $groupManager, ActivityManager $activityManager, @@ -105,6 +105,7 @@ class BoardService { ) { $this->boardMapper = $boardMapper; $this->stackMapper = $stackMapper; + $this->cardMapper = $cardMapper; $this->labelMapper = $labelMapper; $this->config = $config; $this->aclMapper = $aclMapper; @@ -119,7 +120,6 @@ class BoardService { $this->changeHelper = $changeHelper; $this->userId = $userId; $this->urlGenerator = $urlGenerator; - $this->cardMapper = $cardMapper; $this->connection = $connection; $this->boardServiceValidator = $boardServiceValidator; } @@ -652,6 +652,27 @@ class BoardService { } } + /** + * @param $id + * @return Board + * @throws DoesNotExistException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function export($id) : Board { + if (is_numeric($id) === false) { + throw new BadRequestException('board id must be a number'); + } + + $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); + $board = $this->boardMapper->find($id); + $this->enrichWithCards($board); + $this->enrichWithLabels($board); + + return $board; + } + private function enrichWithStacks($board, $since = -1) { $stacks = $this->stackMapper->findAll($board->getId(), null, null, $since); @@ -698,4 +719,23 @@ class BoardService { $this->boardMapper->flushCache($boardId, $boardOwnerId); unset($this->boardsCache[$boardId]); } + + private function enrichWithCards($board) { + $stacks = $this->stackMapper->findAll($board->getId()); + foreach ($stacks as $stack) { + $cards = $this->cardMapper->findAllByStack($stack->getId()); + $fullCards = []; + foreach ($cards as $card) { + $fullCard = $this->cardMapper->find($card->getId()); + array_push($fullCards, $fullCard); + } + $stack->setCards($fullCards); + } + + if (\count($stacks) === 0) { + return; + } + + $board->setStacks($stacks); + } } diff --git a/src/components/navigation/AppNavigationBoard.vue b/src/components/navigation/AppNavigationBoard.vue index dadbbccee..b857b7b73 100644 --- a/src/components/navigation/AppNavigationBoard.vue +++ b/src/components/navigation/AppNavigationBoard.vue @@ -72,6 +72,13 @@ {{ t('deck', 'Archive board') }} + + + {{ t('deck', 'Export board') }} + {{ board.settings['notify-due'] === 'off' ? t('deck', 'Turn on due date reminders') : t('deck', 'Turn off due date reminders') }} @@ -314,6 +321,9 @@ export default { this.isDueSubmenuActive = false this.updateDueSetting = null }, + actionExport() { + this.boardApi.exportBoard(this.board) + }, }, } diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js index 38b1d746f..f63787dd3 100644 --- a/src/services/BoardApi.js +++ b/src/services/BoardApi.js @@ -149,6 +149,71 @@ export class BoardApi { } } + exportBoard(board) { + return axios.get(this.url(`/boards/${board.id}/export`)) + .then( + (response) => { + const fields = { title: t('deck', 'Card title'), description: t('deck', 'Description'), stackId: t('deck', 'List name'), labels: t('deck', 'Tags'), duedate: t('deck', 'Due date'), createdAt: t('deck', 'Created'), lastModified: t('deck', 'Modified') } + let row = '' + Object.keys(fields).forEach(field => { + row += '"' + fields[field] + '"' + '\t' + }) + + row = row.slice(0, -1) + let CSV = row + '\r\n' + + response.data.stacks.forEach(stack => { + stack.cards.forEach(card => { + row = '' + Object.keys(fields).forEach(field => { + if (field === 'createdAt' || field === 'lastModified') { + const date = new Date(Number(card[field]) * 1000) + row += '"' + date.toLocaleDateString() + '"' + '\t' + } else if (field === 'stackId') { + row += '"' + stack.title + '"' + '\t' + } else if (field === 'labels') { + row += '"' + card[field].forEach(label => { + row += label.title + ', ' + }) + if (card[field].length > 0) { + row = row.slice(0, -1) + } + row += '"' + '\t' + } else { + row += '"' + card[field] + '"' + '\t' + } + }) + row = row.slice(0, -1) + CSV += row + '\r\n' + }) + }) + let charCode = [] + const byteArray = [] + byteArray.push(255, 254) + for (let i = 0; i < CSV.length; ++i) { + charCode = CSV.charCodeAt(i) + byteArray.push(charCode & 0xff) + byteArray.push(charCode / 256 >>> 0) + } + const blob = new Blob([new Uint8Array(byteArray)], { type: 'text/csv;charset=UTF-16LE;' }) + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl // 'data:' + data + a.download = response.data.title + '.csv' + a.click() + a.remove() + return Promise.resolve() + }, + (err) => { + return Promise.reject(err) + } + ) + .catch((err) => { + return Promise.reject(err) + }) + } + // Label API Calls deleteLabel(id) { return axios.delete(this.url(`/labels/${id}`)) diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 96007cc21..a54697132 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -114,6 +114,7 @@ class BoardServiceTest extends TestCase { $this->service = new BoardService( $this->boardMapper, $this->stackMapper, + $this->cardMapper, $this->config, $this->l10n, $this->labelMapper, @@ -121,7 +122,6 @@ class BoardServiceTest extends TestCase { $this->permissionService, $this->notificationHelper, $this->assignedUsersMapper, - $this->cardMapper, $this->userManager, $this->groupManager, $this->activityManager,