1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-12-24 13:43:12 +02:00

Import from Notion

This commit is contained in:
Chen-I Lim 2021-02-22 10:58:50 -08:00
parent 32cd2096b3
commit 531662b6ec
9 changed files with 1762 additions and 2 deletions

View File

@ -1,4 +1,4 @@
# Import scripts
This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of importing from Trello and Asana. [Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.
This subfolder contains scripts to import data from other systems. It is at an early stage. At present, there are examples of basic importing from Trello, Asana, and Notion. [Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

View File

@ -0,0 +1,103 @@
{
"extends": [
],
"plugins": [
],
"parser": "@typescript-eslint/parser",
"env": {
"jest": true
},
"settings": {
"import/resolver": "webpack",
"react": {
"pragma": "React",
"version": "detect"
}
},
"rules": {
"no-unused-expressions": 0,
"eol-last": ["error", "always"],
"import/no-unresolved": 2,
"no-undefined": 0,
"react/jsx-filename-extension": 0,
"max-nested-callbacks": ["error", {"max": 5}]
},
"overrides": [
{
"files": ["**/*.tsx", "**/*.ts"],
"extends": [
"plugin:@typescript-eslint/recommended"
],
"rules": {
"import/no-unresolved": 0, // ts handles this better
"camelcase": 0,
"semi": "off",
"@typescript-eslint/naming-convention": [
2,
{
"selector": "function",
"format": ["camelCase", "PascalCase"]
},
{
"selector": "variable",
"format": ["camelCase", "PascalCase", "UPPER_CASE"]
},
{
"selector": "parameter",
"format": ["camelCase", "PascalCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/prefer-interface": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/semi": [2, "never"],
"@typescript-eslint/indent": [
2,
4,
{
"SwitchCase": 0
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": [
2,
{
"classes": false,
"functions": false,
"variables": false
}
],
"no-useless-constructor": 0,
"@typescript-eslint/no-useless-constructor": 2,
"react/jsx-filename-extension": 0
}
},
{
"files": ["tests/**", "**/*.test.*"],
"env": {
"jest": true
},
"rules": {
"func-names": 0,
"global-require": 0,
"new-cap": 0,
"prefer-arrow-callback": 0,
"no-import-assign": 0
}
}
]
}

1
import/notion/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
test

View File

@ -1,4 +1,17 @@
# Notion importer
Placeholder for now. [Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.
This node app converts a Notion CSV and markdown export into a Focalboard archive. To use:
1. From a Notion Board, open the ... menu at the top right
2. Select `Export` and pick `Markdown & CSV` as the export format
3. Save it locally, and unzip the folder e.g. to `notion-export`
4. Run `npm install`
5. Run `npx ts-node importNotion.ts -i <path to the notion-export folder> -o archive.focalboard`
6. In Focalboard, click `Settings`, then `Import archive` and select `archive.focalboard`
## Import scope
Currently, the script imports all cards from a single board, including their properties and markdown content.
The Notion export format does not preserve property types, so the script currently imports all card properties as a Select type. You can change the type after importing into Focalboard.
[Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

View File

@ -0,0 +1,227 @@
import csv from 'csvtojson'
import * as fs from 'fs'
import minimist from 'minimist'
import path from 'path'
import {exit} from 'process'
import {IArchive} from '../../webapp/src/blocks/archive'
import {IBlock} from '../../webapp/src/blocks/block'
import {IPropertyTemplate, MutableBoard} from '../../webapp/src/blocks/board'
import {MutableBoardView} from '../../webapp/src/blocks/boardView'
import {MutableCard} from '../../webapp/src/blocks/card'
import {MutableTextBlock} from '../../webapp/src/blocks/textBlock'
import {Utils} from './utils'
// HACKHACK: To allow Utils.CreateGuid to work
(global.window as any) = {}
let markdownFolder: string
const optionColors = [
// 'propColorDefault',
'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 inputFolder = args['i']
const outputFile = args['o'] || 'archive.focalboard'
if (!inputFolder) {
showHelp()
}
if (!fs.existsSync(inputFolder)){
console.log(`Folder not found: ${inputFolder}`)
exit(2)
}
const inputFile = getCsvFilePath(inputFolder)
if (!inputFile) {
console.log(`.csv file not found in folder: ${inputFolder}`)
exit(2)
}
console.log(`inputFile: ${inputFile}`)
// Read input
const input = await csv().fromFile(inputFile)
console.log(`Read ${input.length} rows.`)
console.log(input)
const basename = path.basename(inputFile, '.csv')
const components = basename.split(' ')
components.pop()
const title = components.join(' ')
console.log(`title: ${title}`)
markdownFolder = path.join(inputFolder, basename)
// Convert
const output = convert(input, title)
// Save output
const outputData = JSON.stringify(output)
fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`)
}
function getCsvFilePath(inputFolder: string): string | undefined {
const files = fs.readdirSync(inputFolder)
const file = files.find(o => path.extname(o).toLowerCase() === '.csv')
return file ? path.join(inputFolder, file) : undefined
}
function getMarkdown(cardTitle: string): string | undefined {
const files = fs.readdirSync(markdownFolder)
const file = files.find((o) => {
const basename = path.basename(o)
const components = basename.split(' ')
const fileCardTitle = components.slice(0, components.length-1).join(' ')
if (fileCardTitle === cardTitle) {
return o
}
})
if (file) {
const filePath = path.join(markdownFolder, file)
const markdown = fs.readFileSync(filePath, 'utf-8')
// TODO: Remove header from markdown, which repets card title and properties
return markdown
}
return undefined
}
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 convert(input: any[], title: string): IArchive {
const blocks: IBlock[] = []
const archive: IArchive = {
version: 1,
date: Date.now(),
blocks
}
// Board
const board = new MutableBoard()
console.log(`Board: ${title}`)
board.rootId = board.id
board.title = title
// Each column is a card property
const columns = getColumns(input)
columns.forEach(column => {
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
blocks.push(board)
// Board view
const view = new MutableBoardView()
view.title = 'Board View'
view.viewType = 'board'
view.rootId = board.id
view.parentId = board.id
blocks.push(view)
// Cards
input.forEach(row => {
const keys = Object.keys(row)
console.log(keys)
if (keys.length < 1) {
console.error(`Expected at least one column`)
return archive
}
const titleKey = keys[0]
const title = row[titleKey]
console.log(`Card: ${title}`)
const outCard = new MutableCard()
outCard.title = title
outCard.rootId = 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 (!value) {
// Skip empty values
continue
}
const cardProperty = board.cardProperties.find((o) => o.name === key)!
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.properties[cardProperty.id] = option.id
}
blocks.push(outCard)
// Card notes from markdown
const markdown = getMarkdown(title)
if (markdown) {
console.log(`Markdown: ${markdown.length} bytes`)
const text = new MutableTextBlock()
text.title = markdown
text.rootId = board.id
text.parentId = outCard.id
blocks.push(text)
outCard.contentOrder = [text.id]
}
})
console.log('')
console.log(`Found ${input.length} card(s).`)
return archive
}
function showHelp() {
console.log('import -i <input.json> -o [output.focalboard]')
exit(1)
}
main()

1344
import/notion/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "focalboard-notion-importer",
"version": "1.0.0",
"private": true,
"description": "",
"main": "importNotion.js",
"scripts": {
"lint": "eslint --ext .tsx,.ts . --quiet --cache",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache",
"test": "ts-node importNotion.ts -i test/export -o test/archive.focalboard",
"debug:test": "node --inspect=5858 -r ts-node/register importNotion.ts -i test/export -o test/archive.focalboard"
},
"keywords": [],
"author": "",
"devDependencies": {
"@types/minimist": "^1.2.1",
"@types/node": "^14.14.28",
"@typescript-eslint/eslint-plugin": "^4.15.0",
"@typescript-eslint/parser": "^4.15.0",
"eslint": "^7.20.0",
"ts-node": "^9.1.1",
"typescript": "^4.1.5"
},
"dependencies": {
"csvtojson": "^2.0.10",
"minimist": "^1.2.5"
}
}

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"jsx": "react",
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"noImplicitAny": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"allowJs": true,
"resolveJsonModule": true,
"incremental": false,
"outDir": "./dist",
"moduleResolution": "node"
},
"include": [
"."
],
"exclude": [
".git",
"**/node_modules/*",
"dist",
"pack"
]
}

17
import/notion/utils.ts Normal file
View File

@ -0,0 +1,17 @@
import * as crypto from 'crypto'
class Utils {
static createGuid(): string {
function randomDigit() {
if (crypto && crypto.randomBytes) {
const rands = crypto.randomBytes(1)
return (rands[0] % 16).toString(16)
}
return (Math.floor((Math.random() * 16))).toString(16)
}
return 'xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx'.replace(/x/g, randomDigit)
}
}
export { Utils }