Skip to content

[pr#4920] [GH-450] Feature: Add import csv feature #92

Description

@officialjaware

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions