From 599347b26dbd49a2aeb57e177d6df7056909ad39 Mon Sep 17 00:00:00 2001 From: omggga Date: Mon, 16 Oct 2023 14:46:07 +0300 Subject: [PATCH] Import from trello: comments, images, attachments --- import/README.md | 95 +++++++++++++++++++++++++++++++++++ import/trello/importTrello.ts | 78 ++++++++++++++++++++++++++-- import/trello/trello.ts | 2 + import/util/archive.ts | 8 ++- 4 files changed, 176 insertions(+), 7 deletions(-) diff --git a/import/README.md b/import/README.md index a9f15658c79..d81e7fda98a 100644 --- a/import/README.md +++ b/import/README.md @@ -8,4 +8,99 @@ This subfolder contains scripts to import data from other systems. It is at an e * Todoist * Nextcloud Deck +## Trello + +The base structure for importing archive is the same for every method: + +``` +- board.boardarchive: a normal zip archive with changed extension +-- version.json version metadata +-- {boardId}: folder with name equal to boardId +--- board.jsonl jsonl board metadata generated for board with importTrello.ts +--- {attachments} attachments from trello in format {trello_attachment_id}.{extensions} +``` + +To create board.jsonl use *.json board data file from trello and command: +``` +node -r ts-node/register importTrello.ts -i {trello_board_data}.json -o board.jsonl +``` + +Add attachments to the {boardId} folder, the fastest way is to download them thourgh the same {trello_board_data}.json (you can get origin json from trello export option) with your API keys, the name pattern is `${trello_attachment_id}.${fileExtension}`: +
+ NodeJS example + + ```javascript + const fs = require('fs'); + const fetch = require('node-fetch'); + const path = require('path'); + + const API_KEY = 'XXXXXX'; + const TOKEN = 'XXXXXX'; + const baseURL = 'https://api.trello.com/1'; + + async function downloadFile(metaUrl, dest, card) { + const headers = { + 'Authorization': `OAuth oauth_consumer_key="${API_KEY}", oauth_token="${TOKEN}"` + } + + let response = await fetch(metaUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to fetch metadata from ${metaUrl}. Status: ${response.statusText}`); + } + + const metadata = await response.json(); + + // Now, fetch the actual file using the provided download format + const fileUrl = `https://api.trello.com/1/cards/${card.id}/attachments/${metadata.id}/download/${metadata.fileName}`; + response = await fetch(fileUrl, { headers }); + + if (!response.ok) { + throw new Error(`Failed to fetch file from ${fileUrl}. Status: ${response.statusText}`); + } + + const buffer = await response.buffer(); + await fs.promises.writeFile(dest, buffer); + } + + async function main() { + try { + const data = JSON.parse(fs.readFileSync('data.json', 'utf8')); + + if (!data.cards || data.cards.length === 0) { + console.log("No cards found."); + return; + } + + console.log('Cards: ' + data.cards.length) + + for (const card of data.cards) { + if (!card.attachments || card.attachments.length === 0) { + console.log(`Card ${card.id} has no attachments.`); + continue; + } + + for (const attachment of card.attachments) { + const fileExtension = path.extname(attachment.fileName || ''); + const fileName = `${attachment.id}${fileExtension}`; + + // Build the Trello API URL + const downloadUrl = `${baseURL}/cards/${card.id}/attachments/${attachment.id}?key=${API_KEY}&token=${TOKEN}`; + try { + await downloadFile(downloadUrl, `./downloads/${fileName}`, card); + console.log(`File saved as ./downloads/${fileName}`); + } catch (err){ + //Sometimes attachs cannot be downloaded + console.log(err) + } + } + } + } catch (error) { + console.error(`Error: ${error.message}`); + } + } + + main() + ``` +
+ [Contribute code](https://mattermost.github.io/focalboard/) to expand this. diff --git a/import/trello/importTrello.ts b/import/trello/importTrello.ts index 91c7e28dfe5..2b79e090c12 100644 --- a/import/trello/importTrello.ts +++ b/import/trello/importTrello.ts @@ -6,6 +6,9 @@ import {exit} from 'process' import {ArchiveUtils} from '../util/archive' import {Block} from '../../webapp/src/blocks/block' import {Board} from '../../webapp/src/blocks/board' +import {createAttachmentBlock} from '../../webapp/src/blocks/attachmentBlock' +import {createImageBlock} from '../../webapp/src/blocks/imageBlock' +import {createCommentBlock} from '../../webapp/src/blocks/commentBlock' import {IPropertyOption, IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board' import {createBoardView} from '../../webapp/src/blocks/boardView' import {createCard} from '../../webapp/src/blocks/card' @@ -112,6 +115,8 @@ function convert(input: Trello): [Board[], Block[]] { outCard.title = card.name outCard.boardId = board.id outCard.parentId = board.id + const updateDate = new Date(card.dateLastActivity) + outCard.updateAt = updateDate.getTime() // Map lists to Select property options if (card.idList) { @@ -127,6 +132,11 @@ function convert(input: Trello): [Board[], Block[]] { blocks.push(outCard) + if (!outCard.fields.contentOrder) { + outCard.fields.contentOrder = [] + } + + //Description if (card.desc) { // console.log(`\t${card.desc}`) const text = createTextBlock() @@ -134,16 +144,60 @@ function convert(input: Trello): [Board[], Block[]] { text.boardId = board.id text.parentId = outCard.id blocks.push(text) + outCard.fields.contentOrder.push(text.id) + } - outCard.fields.contentOrder = [text.id] + // Attachments + if (card.attachments){ + card.attachments.forEach(attach => { + const attachment = createAttachmentBlock() + const extension = getFileExtension(attach.name) + const name = `${attach.id}.${extension}` + attachment.fields.fileId = name + attachment.boardId = board.id + attachment.parentId = outCard.id + attachment.title = name + + const date = new Date(attach.date) + attachment.createAt = date.getTime() + blocks.push(attachment) + + if (isImageFile(name)){ + const image = createImageBlock() + image.boardId = board.id + image.parentId = outCard.id + image.fields.fileId = name + blocks.push(image) + outCard.fields.contentOrder.push(image.id) + } + }) } + //Iteratin actions to find comments and card createdBy + input.actions.forEach(action => { + if (action.data.card && action.data.card.id === card.id) { + if (action.type === 'createCard') { + const date = new Date(action.date) + outCard.createAt = date.getTime() + } else if (action.type === 'commentCard') { + const comment = createCommentBlock() + comment.boardId = board.id + comment.parentId = outCard.id + const date = new Date(action.date) + comment.createAt = date.getTime() + comment.title = action.data.text! + blocks.push(comment) + outCard.fields.contentOrder.push(comment.id) + } + } + }) + // Add Checklists if (card.idChecklists && card.idChecklists.length > 0) { card.idChecklists.forEach(checklistID => { - const lookup = input.checklists.find(e => e.id === checklistID) + const lookup = card.checklists.find(e => e.id === checklistID) if (lookup) { - lookup.checkItems.forEach(trelloCheckBox=> { + lookup.checkItems.forEach((trelloCheckBox) => { const checkBlock = createCheckboxBlock() checkBlock.title = trelloCheckBox.name if (trelloCheckBox.state === 'complete') { @@ -162,12 +216,26 @@ function convert(input: Trello): [Board[], Block[]] { } }) - console.log('') - console.log(`Found ${input.cards.length} card(s).`) + console.log(`\nFound ${input.cards.length} card(s).`) return [boards, blocks] } +function isImageFile(filename: string): boolean { + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff', 'svg'] + const extension = filename.split('.').pop()?.toLowerCase() + return imageExtensions.includes(extension!) +} + +function getFileExtension(filename: string): string | null { + const parts = filename.split('.') + if (parts.length > 1) { + return parts[parts.length - 1] + } + + return null +} + function showHelp() { console.log('import -i -o [output.boardarchive]') exit(1) diff --git a/import/trello/trello.ts b/import/trello/trello.ts index f2c89f8fd83..1408b71808d 100644 --- a/import/trello/trello.ts +++ b/import/trello/trello.ts @@ -70,6 +70,7 @@ export interface Icon { } export interface Data { + attachment: any; old?: Old; customField?: DataCustomField; customFieldItem?: CustomFieldItem; @@ -306,6 +307,7 @@ export interface CardElement { due: null | string; email: string; idChecklists: string[]; + checklists: ChecklistElement[]; idMembers: IDMemberCreator[]; labels: Label[]; limits: CardLimits; diff --git a/import/util/archive.ts b/import/util/archive.ts index ad740108313..f410a0b7bcc 100644 --- a/import/util/archive.ts +++ b/import/util/archive.ts @@ -25,7 +25,10 @@ interface BoardArchiveLine extends ArchiveLine { } class ArchiveUtils { - static buildBlockArchive(boards: readonly Board[], blocks: readonly Block[]): string { + static buildBlockArchive( + boards: readonly Board[], + blocks: readonly Block[] + ): string { const header: ArchiveHeader = { version: 1, date: Date.now(), @@ -95,4 +98,5 @@ class ArchiveUtils { } } -export {ArchiveHeader, ArchiveLine, BlockArchiveLine, ArchiveUtils} \ No newline at end of file +export {ArchiveHeader, ArchiveLine, BlockArchiveLine, ArchiveUtils} +