Migrate Focalboard Boards to Kanboard

Focalboard · Kanboard · Python · JSON · CSV

TL/DR

Here is the Github repo for the transform script: zemzelett/focalboard-to-kanboard

Preface: Why?

I started using Focalboard last year around October when i started my time-tracker project.

Here is, in short, what bothered me with it:

All of these fed a desire to use something else.

When i first looked for this kind of software i found Kanboard as well and chose Focalboard over it for its nicer and seemingly more mature design.

I can’t say how well Kanboard will perform in the future but it can easily handle a fair amount of tasks on the board and the UI is much more fluid on Desktop and on Mobile! It doesn’t look as nice and fancy but that’s something one can customize if desired.

Preparation

I’ll save you some of the hiccups i’ve had via this short summary: At first i compared the export format for both softwares. At some point i had to realize that the Kanboard import format differs from its export format. This is something to consider when using Kanboard initially too!

Focalboard export files analysis

Focalboard has two types of export formats:

The CSV seems fine if only the task titles are of interest otherwise it will fall short of the archive format.

Analysis of the archive format

The archive, in its essence, is a JSONL. JSONL is, simply put, a file with multiple JSON objects separated by newlines.

This is the first time i heard of this format; but i also don’t quite see the purpose in it to be honest. Why not just use a standard JSON-compliant array of objects to achieve basically the same but have it easily parseable by a JSON parser directly?

But i digress, here are the important observations in regards of this file:

Kanboards import format

As mentioned before, the import format differs from the export format. Kanboard lets you download a template for it in its import dialog. It looks as follows:

1
Reference,Title,Description,"Assignee Username","Creator Username","Color Name","Column Name","Category Name","Swimlane Name",Complexity,"Time Estimated","Time Spent","Start Date","Due Date",Priority,Status,Tags,"External Link"

An import has to have all of these columns present in that specific order or the import won’t work correctly!

Lucky me, it contains the Description column i longed for.

The migration

For the migration i wrote a Python script that takes a board’s archive and transforms its contents into an Kanboard import CSV.

So the migration steps for each board i had where as follows:

  1. Export Focalboard board in its archive format
  2. Run that file through the transform script
  3. Create a Kanboard board with the same columns (Important!)
  4. Import the CSV into a newly created Kanboard board

The script

The script is also available on Github: zemzelett/focalboard-to-kanboard

Parsing arguments

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import argparse
import csv
import json
import zipfile

argumentParser = argparse.ArgumentParser(
    prog='focal-to-kan',
    description='Converts Focalboard archive export to Kanboard import CSV'
)
argumentParser.add_argument(
    'inFile',
    help='Focalboard archive to convert',
    nargs=1,
)
argumentParser.add_argument(
    '-o',
    help='output file name',
    nargs=1
)

args = argumentParser.parse_args()
if not args.inFile:
    print('inFile has to be supplied!')
    exit(1)

I’ll brush over these lines of code. These are just for convenience when running the script. It should mostly speak for itself. If not,read Python’s argparse documentation.

Extracting the boards JSONL

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
root = zipfile.Path(args.inFile[0])

boardDir = None
for part in root.iterdir():
    if part.is_dir():
        boardDir = part
        break
boardDirPath = root.joinpath(boardDir.name)

boardFile = None
for part in boardDirPath.iterdir():
    if part.is_file():
        boardFile = part
        break

focalJsonl = boardFile.open()

This section of code looks for the folder inside the zip archive, jumps into it and grabs hold of the reference to the first file in it. This is our JSONL file containing the board.

For reference look into Python’s zipfile documentation.

Processing the Focalboard’s board content

41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
tags = {}
tagsId = ''
status = {}
statusId = ''
tasks = {}
descriptions = []

while line := focalJsonl.readline():
    o = json.loads(line)
    data = o['data']
    if o['type'] == 'board':
        for o in data['cardProperties']:
            if o['name'] == 'Status':
                statusId = o['id']
                status = {v['id']: v['value'] for v in o['options']}
            elif o['name'] == 'Tags':
                tagsId = o['id']
                tags = {v['id']: v['value'] for v in o['options']}
    elif data['type'] == 'card':
        tasks[data['id']] = data
    elif data['type'] == 'text':
        descriptions.append(data)

Each line (line 48) in the JSONL is a valid JSON object we have to parse (line 49).

We are interested in objects of type board, card and text.

The board’s metadata is contained in the very first lines object with the type board (line 51). In it we look for the cardProperties and extract Status (the board columns) (line 53) and Tags (line 56).

Entries of type card are the actual tasks (lines 59-60).

Entries of type text are potentially descriptions for tasks (lines 61-62). We’ll store all of them for now.

json.loads is able to deserialize a JSON string. Refer to Python’s json documentation for more insight.

Assembling the Kanboard import CSV

 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
rows = [[
    'Reference',
    'Title',
    'Description',
    'Assignee Username',
    'Creator Username',
    'Color Name',
    'Column Name',
    'Category Name',
    'Swimlane Name',
    'Complexity',
    'Time Estimated',
    'Time Spent',
    'Start Date',
    'Due Date',
    'Priority',
    'Status',
    'Tags',
    'External Link'
]]

for d in descriptions:
    if d['parentId'] in tasks:
        tasks[d['parentId']]['description'] = d['title']

for task in tasks.values():
    props = task['fields']['properties']
    rows.append(
        [
            'focalboard import',
            task['title'],
            task.get('description', ''),
            'your_user_here',
            'your_user_here',
            'Grey',
            status[props[statusId]],
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            ','.join(map(lambda v: tags.get(v, ''), props.get(tagsId, [])))
        ]
    )

outFilename = args.o[0] if args.o else f'{args.inFile[0]}.csv'
csvFile = open(outFilename, 'w', newline='')
csv.writer(csvFile).writerows(rows)

Before we can build all rows of the import CSV we have to connect the descriptions to their respective tasks. This happens in lines 84-86. We simply iterate over all “descriptions”. If the parentId is one of our tasks, we assign its title value to the task’s (now new) description property.

The loop at line 88 fills the rows with each task’s relevant properties.

Only line 108 might be of special interest here. It concatenates all of a task’s tags together separated by a comma.

For lines 112-114 refer to Python’s csv documentation.