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:
parent
32cd2096b3
commit
531662b6ec
@ -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.
|
||||
|
||||
|
103
import/notion/.eslintrc.json
Normal file
103
import/notion/.eslintrc.json
Normal 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
1
import/notion/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
test
|
@ -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.
|
||||
|
227
import/notion/importNotion.ts
Normal file
227
import/notion/importNotion.ts
Normal 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
1344
import/notion/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
import/notion/package.json
Normal file
28
import/notion/package.json
Normal 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"
|
||||
}
|
||||
}
|
27
import/notion/tsconfig.json
Normal file
27
import/notion/tsconfig.json
Normal 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
17
import/notion/utils.ts
Normal 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 }
|
Loading…
Reference in New Issue
Block a user