1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-01-23 18:34:02 +02:00

Add importer for Nextcloud Deck (#2210)

This commit is contained in:
Felix Haase 2022-02-04 20:05:23 +01:00 committed by GitHub
parent 50d62db683
commit 6bed46d8bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 4059 additions and 0 deletions

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
}
}
]
}

2
import/nextcloud-deck/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
test
output.focalboard

View File

@ -0,0 +1,16 @@
# Nextcloud Deck importer
This node app converts data from a Nextcloud Server with the [app Deck](https://apps.nextcloud.com/apps/deck) installed into a Focalboard archive. To use:
1. Run `npm install` from within `focalboard/webapp`
2. Run `npm install` from within `focalboard/import/nextcloud-deck`
3. Run `npx ts-node importDeck.ts -o archive.focalboard` (also from within `focalboard/import/nextcloud-deck`)
1. Enter URL and credentials (can also be provided via cli arguments)
2. Enter ID of the board to convert
4. 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 stacks (column) membership, labels, names, descriptions, duedate and comments. [Contribute code](https://www.focalboard.com/contribute/getting-started/) to expand this.

View File

@ -0,0 +1,201 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable @typescript-eslint/no-empty-interface */
// Generated by https://quicktype.io
//
// To change quicktype's target language, run command:
//
// "Set quicktype target language"
import fetch from 'node-fetch'
// Types
export interface Board {
title: string;
owner: User;
color: string;
archived: boolean;
labels: Label[];
acl: any[];
permissions: {
PERMISSION_READ: boolean;
PERMISSION_EDIT: boolean;
PERMISSION_MANAGE: boolean;
PERMISSION_SHARE: boolean;
};
users: User[];
shared: number;
deletedAt: number;
id: number;
lastModified: number;
}
export interface Stack {
title: string;
boardId: number;
deletedAt: number;
lastModified: number;
cards: Card[];
order: number;
id: number;
}
export interface Card {
title: string;
description: string;
stackId: number;
type: "plain";
lastModified: number;
createdAt: number;
labels: Label[];
assignedUsers: unknown;
attachments: unknown;
attachmentCount: unknown;
owner: string;
order: number;
archived: boolean;
duedate: string;
deletedAt: number;
commentsUnread: number;
commentsCount: number;
comments?: Comment[]
id: number;
overdue: number;
}
export interface CommentResponse {
ocs: {
meta: {
status: string;
statuscode: number;
message: string;
};
data: Comment[];
};
}
export interface Comment {
id: number;
objectId: number;
message: string;
actorId: string;
actorType: string;
actorDisplayName: string;
creationDateTime: string;
mentions: [
{
mentionId: string;
mentionType: string;
mentionDisplayName: string;
}
];
replyTo?: Comment;
}
export interface Label {
title: string;
color: string;
cardId: any;
id: number;
}
export interface User {
primaryKey: string;
uid: string;
displayname: string;
}
export interface NextcloudDeckClientConfig {
url: string
auth: Auth
}
export interface Auth {
username: string
password: string
}
// api
export const defaultHeaders = {
"OCS-APIRequest": "true",
"Content-Type": "application/json",
"Accept": "application/json",
}
export class NextcloudDeckClient {
config: NextcloudDeckClientConfig
/**
* Create a new Nextcloud Deck client
*/
constructor(config: NextcloudDeckClientConfig) {
this.config = config
}
async fetchWrapper(path: string): Promise<any> {
const response = await fetch(`${this.config.url}/index.php/apps/deck/api/v1.0/${path}`,
{
method: "GET",
headers: {
...defaultHeaders,
"Authorization": 'Basic ' + Buffer.from(`${this.config.auth.username}:${this.config.auth.password}`).toString('base64')
}
})
if (!response.ok) {
throw new Error(`Request failed with info: ${await response.text()}`)
}
return await response.json()
}
async fetchWrapperOCS(path: string): Promise<any> {
const response = await fetch(`${this.config.url}/ocs/v2.php/apps/deck/api/v1.0/${path}`,
{
method: "GET",
headers: {
...defaultHeaders,
"Authorization": 'Basic ' + Buffer.from(`${this.config.auth.username}:${this.config.auth.password}`).toString('base64')
}
})
if (!response.ok) {
throw new Error(`Request failed with info: ${await response.text()}`)
}
return await response.json()
}
async getBoards(): Promise<Board[]> {
return await this.fetchWrapper('boards')
}
async getBoardDetails(boardId: number): Promise<Board> {
return await this.fetchWrapper(`boards/${boardId}`)
}
async getStacks(boardId: number): Promise<Stack[]> {
return await this.fetchWrapper(`boards/${boardId}/stacks`)
}
async getStacksArchived(boardId: number): Promise<Stack[]> {
return await this.fetchWrapper(`boards/${boardId}/stacks/archived`)
}
async getStackDetails(boardId: number, stackId: number): Promise<Stack> {
return await this.fetchWrapper(`boards/${boardId}/stacks/${stackId}`)
}
async getCardDetails(boardId: number, stackId: number, cardId: number): Promise<Card[]> {
return await this.fetchWrapper(`boards/${boardId}/stacks/${stackId}/cards/${cardId}`)
}
async getComments(cardId: number): Promise<Comment[]> {
const response = await this.fetchWrapperOCS(`cards/${cardId}/comments`) as CommentResponse
return response.ocs.data
}
}

View File

@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as fs from 'fs'
import minimist from 'minimist'
import {exit} from 'process'
import {ArchiveUtils} from '../../webapp/src/blocks/archive'
import {Block} from '../../webapp/src/blocks/block'
import {IPropertyOption, 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 {NextcloudDeckClient, Stack, Board} from './deck'
import {Utils} from './utils'
import readline from 'readline-sync'
import {createCommentBlock} from '../../webapp/src/blocks/commentBlock'
// HACKHACK: To allow Utils.CreateGuid to work
(global.window as any) = {}
const optionColors = [
// 'propColorDefault',
'propColorGray',
'propColorBrown',
'propColorOrange',
'propColorYellow',
'propColorGreen',
'propColorBlue',
'propColorPurple',
'propColorPink',
'propColorRed',
]
async function main() {
const args: minimist.ParsedArgs = minimist(process.argv.slice(2))
console.log("Transform a nextcloud deck into a mattermost Board.")
if (args['h'] || args['help']) {
showHelp()
}
// Get Options
const url = args['url'] ?? readline.question('Nextcloud URL: ')
const username = args['u'] ?? readline.question('Username: ')
const password = args['p'] ?? readline.question('Password: ', {hideEchoBack: true})
const boardIdString = args['b']
const outputFile = args['o'] || 'archive.focalboard'
// Create Client
const deckClient = new NextcloudDeckClient({auth: {username, password}, url})
// Select board (Either from cli or by interactive selection)
const boardId = boardIdString ? parseInt(boardIdString) : await selectBoard(deckClient)
// Get Data
const board = await deckClient.getBoardDetails(boardId)
const stacks = await Promise.all((await deckClient.getStacks(boardId)).map(async s => {
return {
...s,
cards: await Promise.all(s.cards.map(async c => {
if (c.commentsCount > 0) {
c.comments = await deckClient.getComments(c.id)
}
return c
}))
}
}))
// Convert
const blocks = convert(board, stacks)
// // Save output
const outputData = ArchiveUtils.buildBlockArchive(blocks)
fs.writeFileSync(outputFile, outputData)
console.log(`Exported to ${outputFile}`)
}
async function selectBoard(deckClient: NextcloudDeckClient): Promise<number> {
console.log("\nAvailable boards for this user:")
const boards = await deckClient.getBoards()
boards.forEach(b => console.log(`\t${b.id}: ${b.title} (${b.owner.uid})`))
return readline.questionInt("Enter Board ID: ")
}
function convert(deckBoard: Board, stacks: Stack[]): Block[] {
const blocks: Block[] = []
// Board
const board = createBoard()
console.log(`Board: ${deckBoard.title}`)
board.rootId = board.id
board.title = deckBoard.title
let colorIndex = 0
// Convert stacks (columns) to a Select property
const stackOptionsIdMap = new Map<number, string>()
const stackOptions: IPropertyOption[] = []
stacks.forEach(stack => {
const optionId = Utils.createGuid()
stackOptionsIdMap.set(stack.id, optionId)
const color = optionColors[colorIndex % optionColors.length]
colorIndex += 1
const option: IPropertyOption = {
id: optionId,
value: stack.title,
color,
}
stackOptions.push(option)
})
const stackProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'List',
type: 'select',
options: stackOptions
}
// Convert labels (tags) to a Select property
const labelOptionsIdMap = new Map<number, string>()
const labelOptions: IPropertyOption[] = []
deckBoard.labels.forEach(label => {
const optionId = Utils.createGuid()
labelOptionsIdMap.set(label.id, optionId)
const color = optionColors[colorIndex % optionColors.length]
colorIndex += 1
const option: IPropertyOption = {
id: optionId,
value: label.title,
color,
}
labelOptions.push(option)
})
const labelProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Label',
type: 'multiSelect',
options: labelOptions
}
const dueDateProperty: IPropertyTemplate = {
id: Utils.createGuid(),
name: 'Due Date',
type: 'date',
options: []
}
board.fields.cardProperties = [stackProperty, labelProperty, dueDateProperty]
blocks.push(board)
// Board view
const view = createBoardView()
view.title = 'Board View'
view.fields.viewType = 'board'
view.rootId = board.id
view.parentId = board.id
blocks.push(view)
// Cards
stacks.forEach(stack =>
stack.cards.forEach(
card => {
console.log(`Card: ${card.title}`)
const outCard = createCard()
outCard.title = card.title
outCard.rootId = board.id
outCard.parentId = board.id
// Map Stacks to Select property options
const stackOptionId = stackOptionsIdMap.get(card.stackId)
if (stackOptionId) {
outCard.fields.properties[stackProperty.id] = stackOptionId
} else {
console.warn(`Invalid idList: ${card.stackId} for card: ${card.title}`)
}
// Map Labels to Multiselect options
outCard.fields.properties[labelProperty.id] = card.labels?.map(label => labelOptionsIdMap.get(label.id)).filter((id): id is string => !!id)
// Add duedate
if (card.duedate) {
const duedate = new Date(card.duedate)
outCard.fields.properties[dueDateProperty.id] = `{\"from\":${duedate.getTime()}}`
}
blocks.push(outCard)
// Description
if (card.description) {
const text = createTextBlock()
text.title = card.description
text.rootId = board.id
text.parentId = outCard.id
blocks.push(text)
outCard.fields.contentOrder = [text.id]
}
// Add Comments (Author cannot be determined since uid's are different)
card.comments?.forEach(comment => {
const commentBlock = createCommentBlock()
commentBlock.title = comment.message
commentBlock.rootId = board.id
commentBlock.parentId = outCard.id
blocks.push(commentBlock)
})
})
)
console.log('')
console.log(`Transformed Board ${deckBoard.title} into ${blocks.length} blocks.`)
return blocks
}
function showHelp() {
console.log('import [--url <nextcloud-url>] [-u <username>] [-p <password>] [-o <output-path>]')
exit(1)
}
main()

3441
import/nextcloud-deck/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
{
"name": "focalboard-nextcloud-deck-importer",
"version": "1.0.0",
"private": true,
"description": "",
"main": "importDeck.js",
"scripts": {
"lint": "eslint --ext .tsx,.ts . --quiet --cache",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache",
"test": "ts-node importTrello.ts -i test/trello.json -o test/trello-import.focalboard",
"debug:test": "node --inspect=5858 -r ts-node/register importTrello.ts -i test/trello.json -o test/trello-import.focalboard"
},
"keywords": [],
"author": "",
"devDependencies": {
"@types/minimist": "^1.2.1",
"@types/node": "^14.14.28",
"@types/node-fetch": "^2.5.0",
"@types/readline-sync": "^1.4.4",
"@typescript-eslint/eslint-plugin": "^4.15.0",
"@typescript-eslint/parser": "^4.15.0",
"eslint": "^7.20.0",
"minimist": "^1.2.5",
"node-fetch": "^2.6.7",
"readline-sync": "^1.4.10",
"ts-node": "^10.4.0",
"typescript": "^4.5.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"
]
}

View File

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
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 }