Imported from upstream pull request #4920
Summary
Adds CSV import functionality to Focalboard, allowing users to import board data from CSV files, addressing issue #450.
Triage
| Field |
Value |
| Category |
feature |
| Complexity |
medium |
| Area |
frontend |
| 👤 Auto-attempt: NO — Feature PR requiring UI/UX review, integration testing, and CSV parsing edge-case validation; too complex for auto-attempt |
|
| Test hint |
Upload various CSV files (empty, malformed, large, with special characters, different delimiters) and verify cards/properties are correctly created on the board; also test error handling for invalid files. |
Original description
Summary
Adds the import CSV feature
Ticket Link
mattermost-community#450
Original patch
diff --git a/import/csv/.gitignore b/import/csv/.gitignore
new file mode 100644
index 00000000000..9daeafb9864
--- /dev/null
+++ b/import/csv/.gitignore
@@ -0,0 +1 @@
+test
diff --git a/import/csv/README.md b/import/csv/README.md
new file mode 100644
index 00000000000..1c24277d57a
--- /dev/null
+++ b/import/csv/README.md
@@ -0,0 +1,16 @@
+# CSV importer
+
+This node app converts a CSV into a Focalboard archive. To use:
+1. Run `npm install` from within `focalboard/webapp`
+2. Run `npm install` from within `focalboard/import/csv`
+3. Run `npx ts-node importCsv.ts -i <path to csv> -o archive.boardarchive`
+ - If the csv was exported by testrails, pass `-t true` into the command line arguments
+4. In Focalboard, click `Settings`, then `Import archive` and select `archive.boardarchive`
+
+## Import scope
+
+Currently, the script imports all cards from a single board, including their properties and markdown content.
+
+The script currently imports all card properties as a Select type. You can change the type after importing into Focalboard.
+
+[Contribute code](https://mattermost.github.io/focalboard/) to expand this.
diff --git a/import/csv/importCsv.ts b/import/csv/importCsv.ts
new file mode 100755
index 00000000000..79a917090c8
--- /dev/null
+++ b/import/csv/importCsv.ts
@@ -0,0 +1,216 @@
+import archiver from 'archiver';
+import { customAlphabet } from 'nanoid';
+import csv from 'csvtojson'
+import * as fs from 'fs'
+import minimist from 'minimist'
+import path from 'path'
+import {exit} from 'process'
+import {ArchiveUtils} from '../util/archive'
+import {Block} from '../../webapp/src/blocks/block'
+import {Board} from '../../webapp/src/blocks/board'
+import {IPropertyTemplate, createBoard} from '../../webapp/src/blocks/board'
+import {createBoardView} from '../../webapp/src/blocks/boardView'
+import {createCard} from '../../webapp/src/blocks/card'
+import {createTextBlock} from '../../webapp/src/blocks/textBlock'
+import {Utils} from './utils'
+
+(global.window as any) = {}
+
+const optionColors = [
+ 'propColorGray',
+ 'propColorBrown',
+ 'propColorOrange',
+ 'propColorYellow',
+ 'propColorGreen',
+ 'propColorBlue',
+ 'propColorPurple',
+ 'propColorPink',
+ 'propColorRed',
+]
+let optionColorIndex = 0
+
+async function main() {
+ const args: minimist.ParsedArgs = minimist(process.argv.slice(2))
+
+ const inputFile = args['i']
+ const outputFile = args['o'] || 'test/archive.boardarchive'
+ const testrailFormat = (args['t'] === 'true') || false
+
+ if (!inputFile) {
+ showHelp()
+ }
+
+ if (!fs.existsSync(inputFile)){
+ console.log(`File not found: ${inputFile}`)
+ exit(2)
+ }
+
+ console.log(`InputFile: ${inputFile}`)
+ const input = await csv().fromFile(inputFile)
+ console.log(`Read ${input.length} rows.`)
+ console.log(input)
+
+ const title = path.basename(inputFile, '.csv')
+ console.log(`Title: ${title}`)
+
+ const [boards, blocks] = convert(input, title, testrailFormat)
+ const outputData = ArchiveUtils.buildBlockArchive(boards, blocks)
+
+ // split output file of version line + boardLines into two fields
+ const [version, ...boardLines] = outputData.split('\n');
+ const boardData = boardLines.join('\n');
+
+ // Generate a UUID for the board directory
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
+ const generateBase32Identifier = customAlphabet(alphabet, 27);
+
+ // Generate an identifier
+ const newIdentifier = generateBase32Identifier();
+
+ // Create a zip archive in memory
+ const output = fs.createWriteStream(outputFile);
+ const archive = archiver('zip', { zlib: { level: 9 } });
+
+ output.on('close', () => {
+ console.log(`Archive created successfully: ${outputFile} (${archive.pointer()} total bytes)`);
+ });
+
+ archive.on('error', (err) => {
+ throw err;
+ });
+
+ archive.pipe(output);
+
+ // Add version.json to the root of the archive
+ archive.append(version, { name: 'version.json' });
+
+ // Add board.jsonl to a UUID-named directory within the archive
+ archive.append(boardData, { name: `${newIdentifier}/board.jsonl` });
+
+ // Finalize the archive
+ await archive.finalize();
+
+ // fs.writeFileSync(outputFile, outputData)
+ console.log(`Exported to ${outputFile}`)
+}
+
+function convert(input: any[], title: string, testrailFormat: boolean): [Board[], Block[]] {
+ const boards: Board[] = []
+ const blocks: Block[] = []
+
+ // Board
+ const board = createBoard()
+ console.log(`Board: ${title}`)
+ board.title = title
+
+ // Each column is a card property
+ const columns = getColumns(input)
+ columns.forEach(column => {
+ if(column === "Description" && testrailFormat) {
+ return
+ } else {
+ const cardProperty: IPropertyTemplate = {
+ id: Utils.createGuid(),
+ name: column,
+ type: 'select',
+ options: []
+ }
+ board.cardProperties.push(cardProperty)
+ }
+ })
+
+ // Set all column types to select
+ // TODO: Detect column type
+ boards.push(board)
+
+ // Board view
+ const view = createBoardView()
+ view.title = 'Board View'
+ view.fields.viewType = 'board'
+ view.boardId = board.id
+ view.parentId = board.id
+ blocks.push(view)
+
+ // Cards
+
+ // Card properties that shouldn't be read into options
+ const excludedProperties = ["URL", "Author", "Author Username", "Assignee", "Created At (UTC)", "Created At (UTC)", "Closed At (UTC)", "Issue ID", "Updated At (UTC)", "Labels" ];
+
+ input.forEach(row => {
+ const keys = Object.keys(row)
+ console.log(keys)
+ if (keys.length < 1) {
+ console.error(`Expected at least one column`)
+ return blocks
+ }
+ const titleKey = keys[0]
+ const title = row[titleKey]
+
+ console.log(`Card: ${title}`)
+
+ const outCard = createCard()
+ outCard.title = title
+ outCard.boardId = board.id
+ outCard.parentId = board.id
+
+ // Card properties, skip first key which is the title
+ for (const key of keys.slice(1)) {
+ const value = row[key]
+ if(key === "Description" && testrailFormat) {
+ const block = createTextBlock()
+ block.title = value
+ block.boardId = board.id
+ block.parentId = outCard.id
+ blocks.push(block)
+
+ outCard.fields.contentOrder = [block.id]
+ continue
+ }
+ if (!value) {
+ // Skip empty values
+ continue
+ }
+
+ const cardProperty = board.cardProperties.find((o) => o.name === key)!
+ // Check if the property is excluded from having options
+ if (excludedProperties.includes(cardProperty.name)) {
+ console.log(`Skipping options for property: ${cardProperty.name}`);
+ continue;
+ }
+ let option = cardProperty.options.find((o) => o.value === value)
+ if (!option) {
+ const color = optionColors[optionColorIndex % optionColors.length]
+ optionColorIndex += 1
+ option = {
+ id: Utils.createGuid(),
+ value,
+ color: color,
+ }
+ cardProperty.options.push(option)
+ }
+
+ outCard.fields.properties[cardProperty.id] = option.id
+ }
+
+ blocks.push(outCard)
+ })
+
+ console.log('')
+ console.log(`Found ${input.length} card(s).`)
+
+ return [boards, blocks]
+}
+
+function getColumns(input: any[]) {
+ const row = input[0]
+ const keys = Object.keys(row)
+ // The first key (column) is the card title
+ return keys.slice(1)
+}
+
+function showHelp() {
+ console.log('import -i <input
Summary
Adds CSV import functionality to Focalboard, allowing users to import board data from CSV files, addressing issue #450.
Triage
featuremediumfrontendUpload various CSV files (empty, malformed, large, with special characters, different delimiters) and verify cards/properties are correctly created on the board; also test error handling for invalid files.Original description
Summary
Adds the import CSV feature
Ticket Link
mattermost-community#450
Original patch