From 88a9fe216113408cb232f1dfc8b00d835c7ddbfe Mon Sep 17 00:00:00 2001 From: David Loe Date: Mon, 10 May 2021 15:03:35 +0200 Subject: [PATCH] Export Board Signed-off-by: David Loe --- .eslintrc.js | 2 +- appinfo/routes.php | 1 + cypress.config.js | 26 +++---- lib/Controller/BoardController.php | 9 +++ lib/Service/BoardService.php | 47 ++++++++++++- src/components/board/SharingTabSidebar.vue | 2 +- src/components/card/AttachmentList.vue | 2 +- src/components/card/Description.vue | 2 +- .../navigation/AppNavigationBoard.vue | 10 +++ src/components/search/GlobalSearchResults.vue | 2 +- src/mixins/attachmentUpload.js | 2 +- src/services/BoardApi.js | 69 ++++++++++++++++++- src/services/StackApi.js | 2 +- src/store/main.js | 2 +- src/views/FileSharingPicker.js | 2 +- 15 files changed, 153 insertions(+), 27 deletions(-) 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/lib/Controller/BoardController.php b/lib/Controller/BoardController.php index 0e909c305..a39ad58a5 100644 --- a/lib/Controller/BoardController.php +++ b/lib/Controller/BoardController.php @@ -169,4 +169,13 @@ class BoardController extends ApiController { return new DataResponse([], HTTP::STATUS_UNAUTHORIZED); } + + /** + * @NoAdminRequired + * @param $boardId + */ + public function export($boardId) { + + return $this->boardService->export($boardId); + } } diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index b63b57759..c9b066b07 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -63,7 +63,8 @@ use Psr\Container\NotFoundExceptionInterface; class BoardService { private BoardMapper $boardMapper; private StackMapper $stackMapper; - private LabelMapper $labelMapper; + private LabelMapper $cardMapper; + private $labelMapper; private AclMapper $aclMapper; private IConfig $config; private IL10N $l10n; @@ -85,6 +86,7 @@ class BoardService { public function __construct( BoardMapper $boardMapper, StackMapper $stackMapper, + CardMapper $cardMapper, IConfig $config, IL10N $l10n, LabelMapper $labelMapper, @@ -92,7 +94,6 @@ class BoardService { PermissionService $permissionService, NotificationHelper $notificationHelper, AssignmentMapper $assignedUsersMapper, - CardMapper $cardMapper, IUserManager $userManager, IGroupManager $groupManager, ActivityManager $activityManager, @@ -105,6 +106,7 @@ class BoardService { ) { $this->boardMapper = $boardMapper; $this->stackMapper = $stackMapper; + $this->cardMapper = $cardMapper; $this->labelMapper = $labelMapper; $this->config = $config; $this->aclMapper = $aclMapper; @@ -119,7 +121,6 @@ class BoardService { $this->changeHelper = $changeHelper; $this->userId = $userId; $this->urlGenerator = $urlGenerator; - $this->cardMapper = $cardMapper; $this->connection = $connection; $this->boardServiceValidator = $boardServiceValidator; } @@ -652,6 +653,27 @@ class BoardService { } } + /** + * @param $id + * @return Board + * @throws DoesNotExistException + * @throws \OCA\Deck\NoPermissionException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws BadRequestException + */ + public function export($id) { + 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 +720,23 @@ class BoardService { $this->boardMapper->flushCache($boardId, $boardOwnerId); unset($this->boardsCache[$boardId]); } + + private function enrichWithCards($board, $since = -1) { + $stacks = $this->stackMapper->findAll($board->getId(), null, null, $since); + 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/board/SharingTabSidebar.vue b/src/components/board/SharingTabSidebar.vue index f5707c106..0cdb33fa4 100644 --- a/src/components/board/SharingTabSidebar.vue +++ b/src/components/board/SharingTabSidebar.vue @@ -213,7 +213,7 @@ export default { confirmClasses: 'error', cancel: t('deck', 'Cancel'), }, - async (result) => { + async(result) => { if (result) { try { this.isLoading = true diff --git a/src/components/card/AttachmentList.vue b/src/components/card/AttachmentList.vue index 5282f4c5e..f25070380 100644 --- a/src/components/card/AttachmentList.vue +++ b/src/components/card/AttachmentList.vue @@ -225,7 +225,7 @@ export default { }, shareFromFiles() { picker.pick() - .then(async (path) => { + .then(async(path) => { console.debug(`path ${path} selected for sharing`) if (!path.startsWith('/')) { throw new Error(t('files', 'Invalid path selected')) diff --git a/src/components/card/Description.vue b/src/components/card/Description.vue index 6e6046c18..ccebfad8e 100644 --- a/src/components/card/Description.vue +++ b/src/components/card/Description.vue @@ -259,7 +259,7 @@ export default { updateDescription() { this.descriptionLastEdit = Date.now() clearTimeout(this.descriptionSaveTimeout) - this.descriptionSaveTimeout = setTimeout(async () => { + this.descriptionSaveTimeout = setTimeout(async() => { await this.saveDescription() }, 2500) }, 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/components/search/GlobalSearchResults.vue b/src/components/search/GlobalSearchResults.vue index bf1c61ecc..6570c0086 100644 --- a/src/components/search/GlobalSearchResults.vue +++ b/src/components/search/GlobalSearchResults.vue @@ -71,7 +71,7 @@ const createCancelToken = () => axios.CancelToken.source() function search({ query, cursor }) { const cancelToken = createCancelToken() - const request = async () => axios.get(generateOcsUrl('apps/deck/api/v1.0/search'), { + const request = async() => axios.get(generateOcsUrl('apps/deck/api/v1.0/search'), { cancelToken: cancelToken.token, params: { term: query, diff --git a/src/mixins/attachmentUpload.js b/src/mixins/attachmentUpload.js index c65b23ec2..5c62bbb86 100644 --- a/src/mixins/attachmentUpload.js +++ b/src/mixins/attachmentUpload.js @@ -48,7 +48,7 @@ export default { bodyFormData.append('cardId', this.cardId) bodyFormData.append('type', type) bodyFormData.append('file', file) - await queue.add(async () => { + await queue.add(async() => { try { await this.$store.dispatch('createAttachment', { cardId: this.cardId, diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js index 38b1d746f..c9a312b15 100644 --- a/src/services/BoardApi.js +++ b/src/services/BoardApi.js @@ -38,7 +38,7 @@ export class BoardApi { * Updates a board. * * @param {Board} board the board object to update - * @return {Promise} + * @returns {Promise} */ updateBoard(board) { return axios.put(this.url(`/boards/${board.id}`), board) @@ -63,7 +63,7 @@ export class BoardApi { * @property {string} color * @param {BoardCreateObject} boardData The board data to send. * color the hexadecimal color value formated /[0-9A-F]{6}/i - * @return {Promise} + * @returns {Promise} */ createBoard(boardData) { return axios.post(this.url('/boards'), boardData) @@ -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/src/services/StackApi.js b/src/services/StackApi.js index 26d3e7e04..117c7e1a0 100644 --- a/src/services/StackApi.js +++ b/src/services/StackApi.js @@ -78,7 +78,7 @@ export class StackApi { /** * @param {Stack} stack stack object to create - * @return {Promise} + * @returns {Promise} */ createStack(stack) { return axios.post(this.url('/stacks'), stack) diff --git a/src/store/main.js b/src/store/main.js index 161d2163f..43b50d979 100644 --- a/src/store/main.js +++ b/src/store/main.js @@ -378,7 +378,7 @@ export default new Vuex.Store({ * @param commit.commit * @param commit * @param board The board to update. - * @return {Promise} + * @returns {Promise} */ async updateBoard({ commit }, board) { const storedBoard = await apiClient.updateBoard(board) diff --git a/src/views/FileSharingPicker.js b/src/views/FileSharingPicker.js index af6eeb90b..26e487f4e 100644 --- a/src/views/FileSharingPicker.js +++ b/src/views/FileSharingPicker.js @@ -46,7 +46,7 @@ export default { ComponentVM.$destroy() reject(new Error('Canceled')) }) - ComponentVM.$root.$on('select', async (id) => { + ComponentVM.$root.$on('select', async(id) => { const result = await createShare({ path: self.fileInfo.path + '/' + self.fileInfo.name, shareType: 12,