From a1380bb064471f0f080f4cde408bddca3e1d849b Mon Sep 17 00:00:00 2001 From: fantasticit Date: Fri, 17 Jun 2022 18:48:07 +0800 Subject: [PATCH] feat: import document from markdown file --- .../components/wiki/setting/import/editor.tsx | 60 ++++++++ .../components/wiki/setting/import/index.tsx | 137 ++++++++++++++++++ .../src/components/wiki/setting/index.tsx | 10 +- packages/server/package.json | 1 + .../server/src/dtos/create-document.dto.ts | 6 + packages/server/src/main.ts | 4 + .../server/src/services/document.service.ts | 10 +- pnpm-lock.yaml | 106 ++++++++++++-- 8 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 packages/client/src/components/wiki/setting/import/editor.tsx create mode 100644 packages/client/src/components/wiki/setting/import/index.tsx diff --git a/packages/client/src/components/wiki/setting/import/editor.tsx b/packages/client/src/components/wiki/setting/import/editor.tsx new file mode 100644 index 00000000..9d7ebddc --- /dev/null +++ b/packages/client/src/components/wiki/setting/import/editor.tsx @@ -0,0 +1,60 @@ +import { Toast } from '@douyinfe/semi-ui'; +import { safeJSONStringify } from 'helpers/json'; +import { useEffect, useRef } from 'react'; +import { useEditor } from 'tiptap/core'; +import { AllExtensions } from 'tiptap/core/all-kit'; +import { Collaboration } from 'tiptap/core/extensions/collaboration'; +import { prosemirrorJSONToYDoc } from 'tiptap/core/thritypart/y-prosemirror/y-prosemirror'; +import { markdownToProsemirror } from 'tiptap/markdown/markdown-to-prosemirror'; +import * as Y from 'yjs'; + +let ydoc = null; + +const getYdoc = () => { + if (!ydoc) { + ydoc = new Y.Doc(); + } + return ydoc; +}; + +export const ImportEditor = ({ content, onChange }) => { + const parsed = useRef(false); + + const editor = useEditor( + { + editable: false, + extensions: AllExtensions.concat(Collaboration.configure({ document: getYdoc() })), + content: '', + }, + [] + ); + + useEffect(() => { + if (!content || !editor || !ydoc || parsed.current) return; + + try { + const prosemirrorNode = markdownToProsemirror({ schema: editor.schema, content, needTitle: true }); + + const title = prosemirrorNode.content[0].content[0].text; + editor.commands.setContent(prosemirrorNode); + Y.applyUpdate(getYdoc(), Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(editor.schema, prosemirrorNode))); + const state = Y.encodeStateAsUpdate(getYdoc()); + + onChange({ + title, + content: safeJSONStringify({ default: prosemirrorNode }), + state: Buffer.from(state), + }); + + parsed.current = true; + } catch (e) { + Toast.error('文件内容解析失败,请到 Github 提 issue 寻求解决!'); + } + + return () => { + editor.destroy(); + }; + }, [editor, content, onChange]); + + return null; +}; diff --git a/packages/client/src/components/wiki/setting/import/index.tsx b/packages/client/src/components/wiki/setting/import/index.tsx new file mode 100644 index 00000000..e6083b54 --- /dev/null +++ b/packages/client/src/components/wiki/setting/import/index.tsx @@ -0,0 +1,137 @@ +import { IconUpload } from '@douyinfe/semi-icons'; +import { Button, List, Toast, Typography } from '@douyinfe/semi-ui'; +import type { IWiki } from '@think/domains'; +import { useCreateDocument } from 'data/document'; +import { useToggle } from 'hooks/use-toggle'; +import { useCallback, useRef, useState } from 'react'; + +import { ImportEditor } from './editor'; + +interface IProps { + wikiId: IWiki['id']; +} + +const { Text } = Typography; + +export const Import: React.FC = ({ wikiId }) => { + const { create } = useCreateDocument(); + const $upload = useRef(); + const [uploadFiles, setUploadFiles] = useState([]); + const [texts, setTexts] = useState>({}); + const [payloads, setPayloads] = useState< + Record< + string, + { + title: string; + content: string; + state: Uint8Array; + } + > + >({}); + const [parsedFiles, setParsedFiles] = useState([]); + const [loading, toggleLoading] = useToggle(false); + + const selectFile = useCallback(() => { + $upload.current.click(); + }, []); + + const handleFile = useCallback((e) => { + const files = Array.from(e.target.files) as Array; + + if (!files.length) return; + + files.forEach((file) => { + const fileReader = new FileReader(); + fileReader.onload = function () { + setTexts((texts) => { + texts[file.name] = fileReader.result; + return texts; + }); + + setUploadFiles((files) => { + return files.concat(file.name); + }); + }; + + fileReader.readAsText(file); + }); + }, []); + + const onParsedFile = useCallback((filename) => { + return (payload) => { + setPayloads((payloads) => { + payloads[filename] = payload; + setParsedFiles((files) => files.concat(filename)); + return payloads; + }); + }; + }, []); + + const onDeleteFile = useCallback((toDeleteFilename) => { + return () => { + setPayloads((payloads) => { + const newPayloads = Object.keys(payloads).reduce((accu, filename) => { + if (filename !== toDeleteFilename) { + accu[filename] = payloads[filename]; + } + return accu; + }, {}); + setParsedFiles(Object.keys(newPayloads)); + return newPayloads; + }); + }; + }, []); + + const importFile = useCallback(() => { + toggleLoading(true); + + Promise.all( + Object.keys(payloads).map((filename) => { + return create({ ...payloads[filename], wikiId }); + }) + ) + .then(() => { + Toast.success('文档已导入'); + }) + .finally(() => { + toggleLoading(false); + setTexts({}); + setUploadFiles([]); + setPayloads({}); + setParsedFiles([]); + $upload.current.value = ''; + }); + }, [payloads, toggleLoading, create, wikiId]); + + return ( +
+ + + + + {uploadFiles.map((filename) => { + return ; + })} + + ( + {filename}
} extra={} /> + )} + emptyContent={仅支持 Markdown 文件导入} + /> + + + + ); +}; diff --git a/packages/client/src/components/wiki/setting/index.tsx b/packages/client/src/components/wiki/setting/index.tsx index c158ab35..a1f0e24a 100644 --- a/packages/client/src/components/wiki/setting/index.tsx +++ b/packages/client/src/components/wiki/setting/index.tsx @@ -1,15 +1,17 @@ import { TabPane, Tabs } from '@douyinfe/semi-ui'; +import { IWiki } from '@think/domains'; import { Seo } from 'components/seo'; import { useWikiDetail } from 'data/wiki'; import React from 'react'; import { Base } from './base'; +import { Import } from './import'; import { More } from './more'; import { Privacy } from './privacy'; import { Users } from './users'; interface IProps { - wikiId: string; + wikiId: IWiki['id']; tab?: string; onNavigate: (arg: string) => void; } @@ -18,6 +20,7 @@ const TitleMap = { base: '基础信息', privacy: '隐私管理', users: '成员管理', + import: '导入文档', more: '更多', }; @@ -37,6 +40,11 @@ export const WikiSetting: React.FC = ({ wikiId, tab, onNavigate }) => { + + + + + diff --git a/packages/server/package.json b/packages/server/package.json index a3e7f873..df1bde33 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,6 +34,7 @@ "@types/multer": "^1.4.7", "ali-oss": "^6.16.0", "bcryptjs": "^2.4.3", + "body-parser": "^1.20.0", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "compression": "^1.7.4", diff --git a/packages/server/src/dtos/create-document.dto.ts b/packages/server/src/dtos/create-document.dto.ts index bd683183..f116c39a 100644 --- a/packages/server/src/dtos/create-document.dto.ts +++ b/packages/server/src/dtos/create-document.dto.ts @@ -15,4 +15,10 @@ export class CreateDocumentDto { @IsOptional() readonly templateId?: string; + + @IsOptional() + readonly content?: string; + + @IsOptional() + state?: Buffer; } diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index c9347bfd..d5c462fa 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@pipes/validation.pipe'; import { HttpResponseTransformInterceptor } from '@transforms/http-response.transform'; +import * as bodyParser from 'body-parser'; import * as compression from 'compression'; import * as cookieParser from 'cookie-parser'; import * as express from 'express'; @@ -33,6 +34,9 @@ async function bootstrap() { }) ); + app.use(bodyParser.json({ limit: '100mb' })); + app.use(bodyParser.urlencoded({ limit: '100mb', extended: true })); + app.use(cookieParser()); app.use(compression()); app.use(helmet()); diff --git a/packages/server/src/services/document.service.ts b/packages/server/src/services/document.service.ts index f5d51877..6dbd8e21 100644 --- a/packages/server/src/services/document.service.ts +++ b/packages/server/src/services/document.service.ts @@ -316,13 +316,21 @@ export class DocumentService { ) : -1; + let state = EMPTY_DOCUMNENT.state; + + if ('state' in dto) { + state = Buffer.from(dto.state); + delete dto.state; + } + const data = { - ...dto, createUserId: user.id, isWikiHome, title: '未命名文档', index: maxIndex + 1, ...EMPTY_DOCUMNENT, + ...dto, + state, }; if (dto.templateId) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ed05f3f..43392068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,7 +246,7 @@ importers: eslint: 8.14.0 eslint-config-prettier: 8.5.0_eslint@8.14.0 eslint-plugin-import: 2.26.0_eslint@8.14.0 - eslint-plugin-prettier: 4.0.0_740be41c8168d0cc214a306089357ad0 + eslint-plugin-prettier: 4.0.0_74ebb802163a9b4fa8f89d76ed02f62a eslint-plugin-react: 7.29.4_eslint@8.14.0 eslint-plugin-react-hooks: 4.5.0_eslint@8.14.0 eslint-plugin-simple-import-sort: 7.0.0_eslint@8.14.0 @@ -302,6 +302,7 @@ importers: '@typescript-eslint/parser': ^5.21.0 ali-oss: ^6.16.0 bcryptjs: ^2.4.3 + body-parser: ^1.20.0 class-transformer: ^0.5.1 class-validator: ^0.13.2 compression: ^1.7.4 @@ -361,6 +362,7 @@ importers: '@types/multer': 1.4.7 ali-oss: 6.16.0 bcryptjs: 2.4.3 + body-parser: 1.20.0 class-transformer: 0.5.1 class-validator: 0.13.2 compression: 1.7.4 @@ -1725,7 +1727,7 @@ packages: '@babel/helper-split-export-declaration': 7.16.7 '@babel/parser': 7.16.12 '@babel/types': 7.16.8 - debug: 4.3.3 + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3747,7 +3749,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.3 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -4284,6 +4286,24 @@ packages: type-is: 1.6.18 dev: false + /body-parser/1.20.0: + resolution: {integrity: sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dependencies: + bytes: 3.1.2 + content-type: 1.0.4 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.10.3 + raw-body: 2.5.1 + type-is: 1.6.18 + unpipe: 1.0.0 + dev: false + /bowser/1.9.4: resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} dev: false @@ -4407,6 +4427,11 @@ packages: engines: {node: '>= 0.8'} dev: false + /bytes/3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + dev: false + /call-bind/1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: @@ -5179,10 +5204,20 @@ packages: engines: {node: '>= 0.6'} dev: false + /depd/2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dev: false + /destroy/1.0.4: resolution: {integrity: sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=} dev: false + /destroy/1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dev: false + /detect-newline/3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -5568,6 +5603,22 @@ packages: prettier-linter-helpers: 1.0.0 dev: true + /eslint-plugin-prettier/4.0.0_74ebb802163a9b4fa8f89d76ed02f62a: + resolution: {integrity: sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==} + engines: {node: '>=6.0.0'} + peerDependencies: + eslint: '>=7.28.0' + eslint-config-prettier: '*' + prettier: '>=2.0.0' + peerDependenciesMeta: + eslint-config-prettier: + optional: true + dependencies: + eslint: 8.14.0 + eslint-config-prettier: 8.5.0_eslint@8.14.0 + prettier-linter-helpers: 1.0.0 + dev: true + /eslint-plugin-react-hooks/4.5.0_eslint@8.14.0: resolution: {integrity: sha512-8k1gRt7D7h03kd+SAAlzXkQwWK22BnK6GKZG+FJA6BAGy22CFvl8kCIXKpVux0cCxMWDQUPqSok0LKaZ0aOcCw==} engines: {node: '>=10'} @@ -6119,7 +6170,7 @@ packages: /formstream/1.1.1: resolution: {integrity: sha512-yHRxt3qLFnhsKAfhReM4w17jP+U1OlhUjnKPPtonwKbIJO7oBP0MvoxkRUwb8AU9n0MIkYy5X5dK6pQnbj+R2Q==} dependencies: - destroy: 1.0.4 + destroy: 1.2.0 mime: 2.6.0 pause-stream: 0.0.11 dev: false @@ -6468,13 +6519,24 @@ packages: toidentifier: 1.0.1 dev: false + /http-errors/2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + dev: false + /http-proxy-agent/4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -6492,7 +6554,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -6979,7 +7041,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.3.3 + debug: 4.3.4 istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -8577,6 +8639,13 @@ packages: ee-first: 1.1.1 dev: false + /on-finished/2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: false + /on-headers/1.0.2: resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} @@ -8729,12 +8798,12 @@ packages: dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 get-uri: 3.0.2 http-proxy-agent: 4.0.1 https-proxy-agent: 5.0.0 pac-resolver: 5.0.0 - raw-body: 2.4.2 + raw-body: 2.5.1 socks-proxy-agent: 5.0.1 transitivePeerDependencies: - supports-color @@ -9276,7 +9345,7 @@ packages: engines: {node: '>= 8'} dependencies: agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 http-proxy-agent: 4.0.1 https-proxy-agent: 5.0.0 lru-cache: 5.1.1 @@ -9365,6 +9434,16 @@ packages: unpipe: 1.0.0 dev: false + /raw-body/2.5.1: + resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: false + /react-countdown/2.3.2_react-dom@17.0.2+react@17.0.2: resolution: {integrity: sha512-Q4SADotHtgOxNWhDdvgupmKVL0pMB9DvoFcxv5AzjsxVhzOVxnttMbAywgqeOdruwEAmnPhOhNv/awAgkwru2w==} peerDependencies: @@ -10145,7 +10224,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.3 + debug: 4.3.4 socks: 2.6.1 transitivePeerDependencies: - supports-color @@ -10282,6 +10361,11 @@ packages: engines: {node: '>= 0.6'} dev: false + /statuses/2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: false + /stream-http/2.8.2: resolution: {integrity: sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==} dependencies: