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:
parent
50d62db683
commit
6bed46d8bd
103
import/nextcloud-deck/.eslintrc.json
Normal file
103
import/nextcloud-deck/.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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
2
import/nextcloud-deck/.gitignore
vendored
Normal file
2
import/nextcloud-deck/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
test
|
||||
output.focalboard
|
16
import/nextcloud-deck/README.md
Normal file
16
import/nextcloud-deck/README.md
Normal 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.
|
||||
|
||||
|
201
import/nextcloud-deck/deck.ts
Normal file
201
import/nextcloud-deck/deck.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
221
import/nextcloud-deck/importDeck.ts
Normal file
221
import/nextcloud-deck/importDeck.ts
Normal 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
3441
import/nextcloud-deck/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
import/nextcloud-deck/package.json
Normal file
29
import/nextcloud-deck/package.json
Normal 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"
|
||||
}
|
||||
}
|
27
import/nextcloud-deck/tsconfig.json
Normal file
27
import/nextcloud-deck/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"
|
||||
]
|
||||
}
|
19
import/nextcloud-deck/utils.ts
Normal file
19
import/nextcloud-deck/utils.ts
Normal 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 }
|
Loading…
x
Reference in New Issue
Block a user