From 73402c8447d1597ebd9a2ce3f23fa761a7c2bbd8 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Thu, 19 Jun 2025 09:14:00 +0800 Subject: [PATCH] feat(server): parse ydoc to markdown (#12812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close AI-190 ## Summary by CodeRabbit - **New Features** - Introduced an endpoint to retrieve a document's markdown content and title. - Added backend support for parsing document snapshots directly into markdown format. - **Tests** - Added comprehensive tests and snapshot files for markdown retrieval, including success and error scenarios. - Improved test coverage for content type validation and markdown parsing utilities. - **Documentation** - Enhanced internal documentation through detailed test cases and snapshot references for new markdown features. #### PR Dependency Tree * **PR #12812** πŸ‘ˆ * **PR #12846** * **PR #12811** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --- .../__snapshots__/controller.spec.ts.md | 118 ++++++++++++++++++ .../__snapshots__/controller.spec.ts.snap | Bin 0 -> 1848 bytes .../e2e/doc-service/controller.spec.ts | 42 +++++++ .../doc-service/__tests__/controller.spec.ts | 44 +++++++ .../server/src/core/doc-service/controller.ts | 14 +++ .../reader-from-database.spec.ts.md | 106 ++++++++++++++++ .../reader-from-database.spec.ts.snap | Bin 0 -> 1707 bytes .../__snapshots__/reader-from-rpc.spec.ts.md | 106 ++++++++++++++++ .../reader-from-rpc.spec.ts.snap | Bin 0 -> 1707 bytes .../__tests__/reader-from-database.spec.ts | 25 ++++ .../doc/__tests__/reader-from-rpc.spec.ts | 49 +++++++- .../backend/server/src/core/doc/reader.ts | 49 ++++++++ .../__snapshots__/blocksute.spec.ts.md | 101 +++++++++++++++ .../__snapshots__/blocksute.spec.ts.snap | Bin 12181 -> 12701 bytes .../core/utils/__tests__/blocksute.spec.ts | 11 ++ .../server/src/core/utils/blocksuite.ts | 28 +++++ 16 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md create mode 100644 packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap create mode 100644 packages/backend/server/src/__tests__/e2e/doc-service/controller.spec.ts create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap diff --git a/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md new file mode 100644 index 0000000000..437bb951f2 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md @@ -0,0 +1,118 @@ +# Snapshot report for `src/__tests__/e2e/doc-service/controller.spec.ts` + +The actual snapshot is saved in `controller.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should get doc markdown success + +> Snapshot 1 + + { + markdown: `AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # You own your data, with no compromises␊ + ␊ + ␊ + ## Local-first & Real-time collaborative␊ + ␊ + ␊ + We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊ + ␊ + ␊ + AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ### Blocks that assemble your next docs, tasks kanban or whiteboard␊ + ␊ + ␊ + There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊ + ␊ + ␊ + We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊ + ␊ + ␊ + If you want to learn more about the product design of AFFiNE, here goes the concepts:␊ + ␊ + ␊ + To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊ + ␊ + ␊ + ## A true canvas for blocks in any form␊ + ␊ + ␊ + [Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + "We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊ + ␊ + ␊ + * Quip & Notion with their great concept of "everything is a block"␊ + ␊ + ␊ + * Trello with their Kanban␊ + ␊ + ␊ + * Airtable & Miro with their no-code programable datasheets␊ + ␊ + ␊ + * Miro & Whimiscal with their edgeless visual whiteboard␊ + ␊ + ␊ + * Remnote & Capacities with their object-based tag system␊ + ␊ + ␊ + For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊ + ␊ + ␊ + ## Self Host␊ + ␊ + ␊ + Self host AFFiNE␊ + ␊ + ␊ + ||Title|Tag|␊ + |---|---|---|␊ + |Affine Development|Affine Development|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊ + ␊ + ␊ + ## Affine Development␊ + ␊ + ␊ + For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊ + ␊ + ␊ + ␊ + ␊ + ␊ + `, + title: 'Write, Draw, Plan all at Once.', + } + +## should get doc markdown return null when doc not exists + +> Snapshot 1 + + { + code: 'Not Found', + message: 'Doc not found', + name: 'NOT_FOUND', + status: 404, + type: 'RESOURCE_NOT_FOUND', + } diff --git a/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..5351944f77cefb69f8ffce6dd38909952e73530a GIT binary patch literal 1848 zcmV-82gmq9RzV5HBjYpP?iTwE=6iu{r`aP++wIx&(q2vM@43m;)4x}v#5;5Y?OU+UK^i^46#20tI zb3OYY(@*ci-t`}3S@zQ`%l>^k%RcGd`nY%NaP*pr*z2u+-nnxp>wT2LJE=$;iRTHU?6a0wA1IN&T)VmrvwPz zwpFSw4Vg6)f?prQLoO*zT){2|GioCT(UB-x(JcNzLwHUwLl^aK@+I6*tTnHMg04j4 zWHq#7!q^59m@TNT{jWH2gg_#B76IT$GBbkrwTFKJ}qyznCv5(1FzGH(RaKGPXSHSlKQ(p_UC!7KYZ0X{9+T zKvr~WoTDNFD+HPmC&hV^v~2~oB@7O9ZWkl3W%F8kYVm*yFacDDcQtWJefcU)=@j_7Sbv2d)A(sG_jkf7Ys6qrgb#7s0ndTGO!) z9jFjB7zr99qGG1mRphASV%qo=pG2)73q9I;P77A1JyG#X4mEeev6626pfhacKY#rD z5O%?GRHxpsBMQ>>$2jpub+YXQIgPkZ|GW=0tcFUb&)`?B@vyzzG!y%~URDall6&fX zi>4IjomM2lOI_SbUQPm8%bUx;OnR9f?wSx2h}&sH-n4Q&C{(o0L5&fmCW?|)rzP9) zyf%%ILR+w2t|tXcBFmXSC(WP15jBpZ#(jt$3lj~==5kI4qQMAt!;ZK-wB&id&!L;v zk|G)_eejl0NC!mO&Z!qKj~L6R=sTjy{hb|Er6G>T#?jCRPS-mH2O2DgcqcHH4f=cC zY#fsv!(*11=Xv^Fv&6+T=Xrj1cB1FPvlFb&^0UEUu=?g_yD1Fp(UdHEmA}hweJ&nd z!gSE(HDI4woo-Fhw)Dl;?&&cNe*DAJ*T327?sqnWf-Q$Hwx$?99W0ketOnk=$-S)y zU7DZoXov@A`I(|tcCnR(NrEJzwF}-?ttn}ZWyK2QLb>+1c<1$RRqOX(*FUQcWy~;=qkFm^|MUaaUCBE3Z3SoKpTrI~V)FN|7!K(&c*eaw$k_M_;o) zlxxeK-?yfi3?yQR_qubzg}>=H(~E-T)pwr<6R8h-0cU;qrA3zx%^2ZO|CB9XEBxe5 zuN4BtHaKvt)sB-(PxWL;r$*u>uN!*b+<4t^>p_-fds&vf&a&)pS(g1X%d&rEU;FCe z=7pMW=x8l3zHz`2_PKTC1*;Dl62WR^_|e`X*YPFBHykz_E)P#$?H@in*t=kLOCrWr m(&de7f9+j#66b#9 { + const owner = await app.signup(); + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const docSnapshot = await app.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user: owner, + }); + + const res = await app + .GET(`/rpc/workspaces/${workspace.id}/docs/${docSnapshot.id}/markdown`) + .set('x-access-token', crypto.sign(docSnapshot.id)) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + + t.snapshot(res.body); +}); + +e2e('should get doc markdown return null when doc not exists', async t => { + const owner = await app.signup(); + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const docId = randomUUID(); + const res = await app + .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`) + .set('x-access-token', crypto.sign(docId)) + .expect(404) + .expect('Content-Type', 'application/json; charset=utf-8'); + + t.snapshot(res.body); +}); diff --git a/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts b/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts index 2187bb7945..d8059dc1f4 100644 --- a/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts +++ b/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts @@ -175,6 +175,7 @@ test('should get doc content in json format', async t => { await app .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content`) .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') .expect({ title: 'test title', summary: 'test summary', @@ -184,6 +185,7 @@ test('should get doc content in json format', async t => { await app .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=false`) .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') .expect({ title: 'test title', summary: 'test summary', @@ -205,6 +207,7 @@ test('should get full doc content in json format', async t => { await app .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=true`) .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') .expect({ title: 'test title', summary: 'test summary full', @@ -251,3 +254,44 @@ test('should get workspace content in json format', async t => { }); t.pass(); }); + +test('should get doc markdown in json format', async t => { + const { app } = t.context; + mock.method(t.context.databaseDocReader, 'getDocMarkdown', async () => { + return { + title: 'test title', + markdown: 'test markdown', + }; + }); + + const docId = randomUUID(); + await app + .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`) + .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .expect({ + title: 'test title', + markdown: 'test markdown', + }); + t.pass(); +}); + +test('should 404 when doc markdown not found', async t => { + const { app } = t.context; + + const workspaceId = '123'; + const docId = '123'; + await app + .GET(`/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`) + .set('x-access-token', t.context.crypto.sign(docId)) + .expect({ + status: 404, + code: 'Not Found', + type: 'RESOURCE_NOT_FOUND', + name: 'NOT_FOUND', + message: 'Doc not found', + }) + .expect(404); + t.pass(); +}); diff --git a/packages/backend/server/src/core/doc-service/controller.ts b/packages/backend/server/src/core/doc-service/controller.ts index 513957b1bd..16bdfb0ee2 100644 --- a/packages/backend/server/src/core/doc-service/controller.ts +++ b/packages/backend/server/src/core/doc-service/controller.ts @@ -42,6 +42,20 @@ export class DocRpcController { res.send(doc.bin); } + @SkipThrottle() + @Internal() + @Get('/workspaces/:workspaceId/docs/:docId/markdown') + async getDocMarkdown( + @Param('workspaceId') workspaceId: string, + @Param('docId') docId: string + ) { + const result = await this.docReader.getDocMarkdown(workspaceId, docId); + if (!result) { + throw new NotFound('Doc not found'); + } + return result; + } + @SkipThrottle() @Internal() @Post('/workspaces/:workspaceId/docs/:docId/diff') diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md new file mode 100644 index 0000000000..db9cd1b252 --- /dev/null +++ b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md @@ -0,0 +1,106 @@ +# Snapshot report for `src/core/doc/__tests__/reader-from-database.spec.ts` + +The actual snapshot is saved in `reader-from-database.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should return doc markdown success + +> Snapshot 1 + + { + markdown: `AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # You own your data, with no compromises␊ + ␊ + ␊ + ## Local-first & Real-time collaborative␊ + ␊ + ␊ + We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊ + ␊ + ␊ + AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ### Blocks that assemble your next docs, tasks kanban or whiteboard␊ + ␊ + ␊ + There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊ + ␊ + ␊ + We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊ + ␊ + ␊ + If you want to learn more about the product design of AFFiNE, here goes the concepts:␊ + ␊ + ␊ + To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊ + ␊ + ␊ + ## A true canvas for blocks in any form␊ + ␊ + ␊ + [Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + "We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊ + ␊ + ␊ + * Quip & Notion with their great concept of "everything is a block"␊ + ␊ + ␊ + * Trello with their Kanban␊ + ␊ + ␊ + * Airtable & Miro with their no-code programable datasheets␊ + ␊ + ␊ + * Miro & Whimiscal with their edgeless visual whiteboard␊ + ␊ + ␊ + * Remnote & Capacities with their object-based tag system␊ + ␊ + ␊ + For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊ + ␊ + ␊ + ## Self Host␊ + ␊ + ␊ + Self host AFFiNE␊ + ␊ + ␊ + ||Title|Tag|␊ + |---|---|---|␊ + |Affine Development|Affine Development|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊ + ␊ + ␊ + ## Affine Development␊ + ␊ + ␊ + For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊ + ␊ + ␊ + ␊ + ␊ + ␊ + `, + title: 'Write, Draw, Plan all at Once.', + } diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..d5593e96448b472fb35ed2badeae40ec4a66906e GIT binary patch literal 1707 zcmV;c22}Y$RzV-WSnmSQ>@_dnVr3m7l?%|xace-97)OD zB^N~)Q#(^T)7qY{PFK(FZP*X-hy09uaQ;{zza&)8?8{vwB2GaI2Rl98)m2YD&(pp4 z!)$7~n9KM7q-dh0%aPRF+7bfAHaIA`fCj_7!eEIG`@V?pD7Qov|j z1+tPz4a_*C*|8>=w#JtFuB#h(1}km@M8XD^W^TqXcf7PzRs;_IE-dN5E-6vH0;uprzY?uOP_o2f?H51)1Hdm7?vj1;1~*SeBj0i ziB7!-zvTjLJ~Kfg9Kb1|&7)}u3bq!f9MWc4mR%57)@^G7Qxbv?>{+OU=?b2>IUK;* z(!`=xxOCDC8*T+KL{nG-oN^mABh5^q*-Ne@@g{b$3(IXefk$nKHHC%)jbYcF6gcII z*5=0LR~iGc#t3sFFT3!%9|ft6e;o+G4#Mk-?s28kq$bm)r(nnxq$uyv1?(Px=)E;X zx4QN0;Nj`Xxpe!!X%}g|=MX2_wLW8wYnQkZkjR0`ZV$VHWo;}$)7ogfC2(AlK%fSV z16jF(N=1LW(}ocD?!j+5A|`;8SF_>u^IfzQ4XhpO5D^1{nV2Lj3Xo@A=P^>ktgo3U_ z<76w^8DVS#2`qc6YyT@w93hZM9%daYC!HQN`)#c~B_hLA)1I}iMAbCfE0uYRamJx} zLk`!CLrdKC=DJlZjI?O4@@Wh;`(9sApaZGV@3&gjGPAtwSlRZR(3uU+`-#@fXzMv@ zKvq;W&QTG86#~tOljA%|-nNC>5(WpV+g|comJeLEMO2}i$|++yxWCE&PVrWUhTwvF{zK=%TWThibmSr!W z>VsN~o*CZDx1ZH9`uj&mE;;%{_z((2Xmmk6E7O^%_@#iFJK;=8w|mh|Y~|m7`^N;1 z!E)4~-moJI()GtU@kV{J?F>0dT-CoC15K)l(&=;feQP`%Y!1!D{;rpmLb2hVMjz3F z!m9I%WO%8IqZH*lkhQ$O{F~&L>E*ErF@boH4&?n=j`KoI>k8BuQEH+nX?0q102j4s zj1<~}-EzGtSQ6Q2{)#n!4yV*Oj=Jwd^jMf^NcNX=x)9}4)B`)>=F*a7*&_~}T1$#( ztc<~1LLn81vYk^eUY;_RPtkWomHS6W>Pi!w&5Wao51gKl3J#QSM0^w&%Le^nr;RhR zGkDArvn)&BYnIrnIm@#3`dqJu>vOEu**eej?O(P&PTRl8sK4m4(TIB%-xF@2l37w8OGu1+sUp6E5C){ae-gvsd*O)wAU@qp z4o448wwdiJerNUkA0oU})sL&k^3C%8|&?UCD2f`kP<8=LBzT zH}A0WJ(qYhPrT`}_gvf!UHiuC*8MHzzr3?Q2eyuM-H=}IM=v*qvv literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md new file mode 100644 index 0000000000..f7342844c8 --- /dev/null +++ b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md @@ -0,0 +1,106 @@ +# Snapshot report for `src/core/doc/__tests__/reader-from-rpc.spec.ts` + +The actual snapshot is saved in `reader-from-rpc.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should return doc markdown success + +> Snapshot 1 + + { + markdown: `AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # You own your data, with no compromises␊ + ␊ + ␊ + ## Local-first & Real-time collaborative␊ + ␊ + ␊ + We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊ + ␊ + ␊ + AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ### Blocks that assemble your next docs, tasks kanban or whiteboard␊ + ␊ + ␊ + There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊ + ␊ + ␊ + We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊ + ␊ + ␊ + If you want to learn more about the product design of AFFiNE, here goes the concepts:␊ + ␊ + ␊ + To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊ + ␊ + ␊ + ## A true canvas for blocks in any form␊ + ␊ + ␊ + [Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + "We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊ + ␊ + ␊ + * Quip & Notion with their great concept of "everything is a block"␊ + ␊ + ␊ + * Trello with their Kanban␊ + ␊ + ␊ + * Airtable & Miro with their no-code programable datasheets␊ + ␊ + ␊ + * Miro & Whimiscal with their edgeless visual whiteboard␊ + ␊ + ␊ + * Remnote & Capacities with their object-based tag system␊ + ␊ + ␊ + For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊ + ␊ + ␊ + ## Self Host␊ + ␊ + ␊ + Self host AFFiNE␊ + ␊ + ␊ + ||Title|Tag|␊ + |---|---|---|␊ + |Affine Development|Affine Development|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊ + ␊ + ␊ + ## Affine Development␊ + ␊ + ␊ + For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊ + ␊ + ␊ + ␊ + ␊ + ␊ + `, + title: 'Write, Draw, Plan all at Once.', + } diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..d5593e96448b472fb35ed2badeae40ec4a66906e GIT binary patch literal 1707 zcmV;c22}Y$RzV-WSnmSQ>@_dnVr3m7l?%|xace-97)OD zB^N~)Q#(^T)7qY{PFK(FZP*X-hy09uaQ;{zza&)8?8{vwB2GaI2Rl98)m2YD&(pp4 z!)$7~n9KM7q-dh0%aPRF+7bfAHaIA`fCj_7!eEIG`@V?pD7Qov|j z1+tPz4a_*C*|8>=w#JtFuB#h(1}km@M8XD^W^TqXcf7PzRs;_IE-dN5E-6vH0;uprzY?uOP_o2f?H51)1Hdm7?vj1;1~*SeBj0i ziB7!-zvTjLJ~Kfg9Kb1|&7)}u3bq!f9MWc4mR%57)@^G7Qxbv?>{+OU=?b2>IUK;* z(!`=xxOCDC8*T+KL{nG-oN^mABh5^q*-Ne@@g{b$3(IXefk$nKHHC%)jbYcF6gcII z*5=0LR~iGc#t3sFFT3!%9|ft6e;o+G4#Mk-?s28kq$bm)r(nnxq$uyv1?(Px=)E;X zx4QN0;Nj`Xxpe!!X%}g|=MX2_wLW8wYnQkZkjR0`ZV$VHWo;}$)7ogfC2(AlK%fSV z16jF(N=1LW(}ocD?!j+5A|`;8SF_>u^IfzQ4XhpO5D^1{nV2Lj3Xo@A=P^>ktgo3U_ z<76w^8DVS#2`qc6YyT@w93hZM9%daYC!HQN`)#c~B_hLA)1I}iMAbCfE0uYRamJx} zLk`!CLrdKC=DJlZjI?O4@@Wh;`(9sApaZGV@3&gjGPAtwSlRZR(3uU+`-#@fXzMv@ zKvq;W&QTG86#~tOljA%|-nNC>5(WpV+g|comJeLEMO2}i$|++yxWCE&PVrWUhTwvF{zK=%TWThibmSr!W z>VsN~o*CZDx1ZH9`uj&mE;;%{_z((2Xmmk6E7O^%_@#iFJK;=8w|mh|Y~|m7`^N;1 z!E)4~-moJI()GtU@kV{J?F>0dT-CoC15K)l(&=;feQP`%Y!1!D{;rpmLb2hVMjz3F z!m9I%WO%8IqZH*lkhQ$O{F~&L>E*ErF@boH4&?n=j`KoI>k8BuQEH+nX?0q102j4s zj1<~}-EzGtSQ6Q2{)#n!4yV*Oj=Jwd^jMf^NcNX=x)9}4)B`)>=F*a7*&_~}T1$#( ztc<~1LLn81vYk^eUY;_RPtkWomHS6W>Pi!w&5Wao51gKl3J#QSM0^w&%Le^nr;RhR zGkDArvn)&BYnIrnIm@#3`dqJu>vOEu**eej?O(P&PTRl8sK4m4(TIB%-xF@2l37w8OGu1+sUp6E5C){ae-gvsd*O)wAU@qp z4o448wwdiJerNUkA0oU})sL&k^3C%8|&?UCD2f`kP<8=LBzT zH}A0WJ(qYhPrT`}_gvf!UHiuC*8MHzzr3?Q2eyuM-H=}IM=v*qvv literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts b/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts index eb4de37ef6..e672aedf0e 100644 --- a/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts +++ b/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts @@ -257,3 +257,28 @@ test('should get workspace content with custom avatar', async t => { avatarUrl: `http://localhost:3010/api/workspaces/${workspace.id}/blobs/${avatarKey}`, }); }); + +test('should return doc markdown success', async t => { + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const docSnapshot = await module.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user, + }); + + const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id); + t.snapshot(result); +}); + +test('should read markdown return null when doc not exists', async t => { + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const result = await docReader.getDocMarkdown(workspace.id, randomUUID()); + t.is(result, null); +}); diff --git a/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts b/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts index 5ae92beeba..005ff18fcc 100644 --- a/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts +++ b/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts @@ -5,13 +5,24 @@ import { User, Workspace } from '@prisma/client'; import ava, { TestFn } from 'ava'; import { applyUpdate, Doc as YDoc } from 'yjs'; +import { createModule } from '../../../__tests__/create-module'; +import { Mockers } from '../../../__tests__/mocks'; import { createTestingApp, type TestingApp } from '../../../__tests__/utils'; import { UserFriendlyError } from '../../../base'; import { ConfigFactory } from '../../../base/config'; import { Models } from '../../../models'; -import { DatabaseDocReader, DocReader, PgWorkspaceDocStorageAdapter } from '..'; +import { + DatabaseDocReader, + DocReader, + DocStorageModule, + PgWorkspaceDocStorageAdapter, +} from '..'; import { RpcDocReader } from '../reader'; +const module = await createModule({ + imports: [DocStorageModule], +}); + const test = ava as TestFn<{ models: Models; app: TestingApp; @@ -68,6 +79,12 @@ test.afterEach.always(() => { test.after.always(async t => { await t.context.app.close(); await t.context.docApp.close(); + await module.close(); +}); + +test('should be rpc reader', async t => { + const { docReader } = t.context; + t.true(docReader instanceof RpcDocReader); }); test('should return null when doc not found', async t => { @@ -144,7 +161,6 @@ test('should fallback to database doc reader when endpoint network error', async test('should return doc when found', async t => { const { docReader } = t.context; - t.true(docReader instanceof RpcDocReader); const docId = randomUUID(); const timestamp = Date.now(); @@ -359,3 +375,32 @@ test('should return null when workspace bin meta not exists', async t => { const notExists = await docReader.getWorkspaceContent(randomUUID()); t.is(notExists, null); }); + +test('should return doc markdown success', async t => { + const { docReader } = t.context; + + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const docSnapshot = await module.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user, + }); + + const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id); + t.snapshot(result); +}); + +test('should read markdown return null when doc not exists', async t => { + const { docReader } = t.context; + + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const result = await docReader.getDocMarkdown(workspace.id, randomUUID()); + t.is(result, null); +}); diff --git a/packages/backend/server/src/core/doc/reader.ts b/packages/backend/server/src/core/doc/reader.ts index 05124b89bd..2c6da66edf 100644 --- a/packages/backend/server/src/core/doc/reader.ts +++ b/packages/backend/server/src/core/doc/reader.ts @@ -18,6 +18,7 @@ import { Models } from '../../models'; import { WorkspaceBlobStorage } from '../storage'; import { type PageDocContent, + parseDocToMarkdownFromDocSnapshot, parsePageDoc, parseWorkspaceDoc, } from '../utils/blocksuite'; @@ -33,6 +34,11 @@ export interface WorkspaceDocInfo { avatarUrl?: string; } +export interface DocMarkdown { + title: string; + markdown: string; +} + export abstract class DocReader { protected readonly logger = new Logger(DocReader.name); @@ -59,6 +65,11 @@ export abstract class DocReader { docId: string ): Promise; + abstract getDocMarkdown( + workspaceId: string, + docId: string + ): Promise; + abstract getDocDiff( spaceId: string, docId: string, @@ -171,6 +182,17 @@ export class DatabaseDocReader extends DocReader { return await this.workspace.getDoc(workspaceId, docId); } + async getDocMarkdown( + workspaceId: string, + docId: string + ): Promise { + const doc = await this.workspace.getDoc(workspaceId, docId); + if (!doc) { + return null; + } + return parseDocToMarkdownFromDocSnapshot(workspaceId, docId, doc.bin); + } + async getDocDiff( spaceId: string, docId: string, @@ -304,6 +326,33 @@ export class RpcDocReader extends DatabaseDocReader { } } + override async getDocMarkdown( + workspaceId: string, + docId: string + ): Promise { + const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`; + const accessToken = this.crypto.sign(docId); + try { + const res = await this.fetch(accessToken, url, 'GET'); + if (!res) { + return null; + } + return (await res.json()) as DocMarkdown; + } catch (e) { + if (e instanceof UserFriendlyError) { + throw e; + } + const err = e as Error; + // other error + this.logger.error( + `Failed to fetch doc markdown ${url}, fallback to database doc reader`, + err + ); + // fallback to database doc reader if the error is not user friendly, like network error + return await super.getDocMarkdown(workspaceId, docId); + } + } + override async getDocDiff( workspaceId: string, docId: string, diff --git a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md index 0b02895056..258c0c252e 100644 --- a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md +++ b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md @@ -1366,3 +1366,104 @@ Generated by [AVA](https://avajs.dev). summary: 'AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro. You own your data, with no compromisesLocal-first & Real-time collaborativeWe love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.Blocks that assemble your next docs, tasks kanban or whiteboardThere is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further. ', title: 'Write, Draw, Plan all at Once.', } + +## can parse doc to markdown from doc snapshot + +> Snapshot 1 + + { + markdown: `AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # You own your data, with no compromises␊ + ␊ + ␊ + ## Local-first & Real-time collaborative␊ + ␊ + ␊ + We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊ + ␊ + ␊ + AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ### Blocks that assemble your next docs, tasks kanban or whiteboard␊ + ␊ + ␊ + There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊ + ␊ + ␊ + We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊ + ␊ + ␊ + If you want to learn more about the product design of AFFiNE, here goes the concepts:␊ + ␊ + ␊ + To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊ + ␊ + ␊ + ## A true canvas for blocks in any form␊ + ␊ + ␊ + [Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + "We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊ + ␊ + ␊ + * Quip & Notion with their great concept of "everything is a block"␊ + ␊ + ␊ + * Trello with their Kanban␊ + ␊ + ␊ + * Airtable & Miro with their no-code programable datasheets␊ + ␊ + ␊ + * Miro & Whimiscal with their edgeless visual whiteboard␊ + ␊ + ␊ + * Remnote & Capacities with their object-based tag system␊ + ␊ + ␊ + For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊ + ␊ + ␊ + ## Self Host␊ + ␊ + ␊ + Self host AFFiNE␊ + ␊ + ␊ + ||Title|Tag|␊ + |---|---|---|␊ + |Affine Development|Affine Development|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊ + ␊ + ␊ + ## Affine Development␊ + ␊ + ␊ + For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊ + ␊ + ␊ + ␊ + ␊ + ␊ + `, + title: 'Write, Draw, Plan all at Once.', + } diff --git a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap index 8c97b9a14d0e49523399ff0b0b763569e8899cf6..7c77db4c2a96a57b327c050c3bcdef90315051af 100644 GIT binary patch literal 12701 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 literal 12181 zcmd_vRZJXE)F<%b?pD0T-3Qm=PH~F646cJiu>u8(!{9c!yIUzx7~Fl3!KF9_+U>Wy z+2qUaK5Vl4v^V$d-@6zwUSbXO!GMDa6LJ&KDlqc=R{_^&p++`Fm3>X5WW$s&e`Z32qIj!ZsB+f#y9H zz-_XL6kDvXrYCj!nYx>cCsi37NC+{U-@-63A|-f5+=Yh+q{kSj_!v^tF^I(R3HdO_ zsR#*`>{7;sR#D%+qt)XapF8($b&QCLh2iy|R0ZT4Pd_hho-73?Jq8MFuDhA|)LT_S zAXH=bKi{(!k4Y*o35=HqGA`OH{rN(Z7JaMKPms|YXA`;KKro~3v%rH({p-m_XKPbD zO0DLviBVmP^E=wCL(hVPpDamo`<9u&1+YY*V4Y)!NrxTJbo!6gI@S)pDNl8F%yW+F z6>=^IF`iEqi$s_t>>f30`UpwJU@~TNlo!&Iuql|UQMjdYOrlA@Ez=0|4?VfwvWoB;VP-F^+WbJrUE_fO(zkA&S8w+Uf#UsA)=CNi3^# zylyK4zB0DzHx=)vkrkEfqy@`D(t&kVoivu&g#W?mZL2`;C(-raD%>wRp^p!x|s~)|m>!0ZLWQ^(Q=5rK2T0#jpk%UKh;-;^m>m=zU9BJ~fHr zM=Pqe1_5F%fIOxGKnDjHM?4oC_g+4&uqK+pT6kYCDC5>zO@yS;uo=+!nR%szmDlLQ zPlWk^i?7b(X$c{+Cz-Mm`q+vbggVEw2Vd&x3+2+1yVK}<<4W<^2-I0J?Dq zjc)H8=KC%K!&#A17T>pT3xvXER_>kw3R+`PHTXwJ_N{Njzz>Vs9JKa0k^-s z;yXI^)19xe>YYfAgcSD#jD4+;_^6ZgR(~w8Bw8;nLM3sbOi9*rtRaV&g>T}C0Y6+m#1!ncIywx*g6=he+-G?N*|(tiEj z$Z_;`Hw;I}#%3s2O<0I%$>xI@lk<4wnwrCOuHkBVZ2Bvz$-HS@uWz2SQNh%AR|H76 z3HK*EuuI+8P+drQw@H;PWF1bQk!_jt7ntq3-ebYm!RK=0G%?u(y)=UBuICEj`>i%}`on z_-Y#~6nQyl{j7BorR}M~ma7FSWelfV-{8YCiFcWAnDlbK!I%(V7Bp=hZ2c~H%HdV% zLen;B+xGO?ma83F<~6{$3bD#QT!qVKbkZp}XS2I_of_?Yvdzgff;+7TE$}U`!0S1@ zYF+9IuvQ zvMJDRwy}0Ju`H%OthC_Q8KM`Mqfc{3vc?y>6D)8OTG;&<1d)Yzq1Txq|7}43(}GUQ z6}>jX)AE+a5L=Ur)M6?w;%V0Ean%YG%v&Rl_Gv0A+zU3=d9~5`tqqx!i@*XtI=Ign zp2FDRh4Js-Ic0qAB6c;$+&0B@qR%o5t`s+z!uyM))ke-hbzT=9_0Bi(=R+ z1=>6qDNlsw8{a1uHfI@sXMRj5_vj z5W#B^7fxdy&>bDz$VOAV-WeAM0q`;oW#uK2Z(tWe!eBh^LoA*<4<5IJ;WR`;vp07r z?rsP{-EaPwBPc~Rl;FQaA*y*%O_D^ptH0kcM&AxR+$NFNGDU{bJ(>`R8z9#cWAKRW zTCkw{@z$_JL#!Tyc!SE{{waSu#U6f7c*IFyA-ZRQn?BIWc=bdGf*?<^M%9+#b3}Ek z!W>w8OxOm_VwWMrkhH;Tz6$OLCGrhz)NQT^smwvJdV_!Jn`R+gh!>Gb2S&8uV6ytv z2Gip_lVK~lsNnEo4lEIh_?2U*qrZbZe+8S{IWd8T%q^VLIC3IQ`2G(kPXmhU3ZnTI zV*e^4f7?(-ZJ|pPy)jJH7$r-ZYO>iF7FBx{Q`@kQ*zxZkA_@l}^aLXiJ4bZ6Abuq^ zUw4TZ5bSWG6z{>YbBlR7pJ}Fc-NMy$i79X;vl#N=30=i?bB&Qd(E5mrHUYcv1VN~_ z*kWEVD{ApACWYU2A_lPU{KN!RAa0~L^by|c4*8^CEvkU}w8KR3(Qdj`9<3QC>pnNJ z37eppS9~_t9o%rLsEv-VLdjCyvp}te%{g_{QuKYqi3eJ!;y3`@4RFK_km?uIB9*&8 zKgLv$ZnQQOlY*UK^sVltr1{k-2Gsk@GA?;xUV7q*CTP7r?zYt8x@cafMHMt@0Uc#D zeL8sDOIX6R@uN>(5-;Ww9L{0as4LrV3U8Br@vc^Zz4{&4Y(PJ1n)PVTak&{F;{u)%Rzr8PcNl&>o>y&nu_hlp~ z$X^4y&K|1Y+v*rz=$$=-owk>^7A~q4+}2^7v|Fv*M?vO}n!ny>hl%;)O_JY8%UvXl z!NT1COg2z`{9qUL&gc1J=;zu?go1qN?hTOeS)?>4?i{B1_2w$=%WivUki|xl5y6PL z?IyIN;ZfhovWS+011IX98aq=o2Bo|f9I93dKl8N*OuK0+L$|b7b@brJ>%IKS2aWLW zb}^d9v)$`NUnH~SZgX^qdW&?cf#cOK+XiWJw~NdIt08Mnf9~YKe>q~|_2rPK@ShHu z^&)--^Meev;{wasnPes0+#2g+pW69lZ1m2(6Kh_@bmD3v7C$wEffw48sd7t5xm?^4 z8pi#!8v74usgJLf3ol!yZyhIHYFSSSCw>0dRDMPEMJI{wD^7CMI z3@*EEsX;m-j$13)WM>Z4nDJ`y5MU71BfaqZZDACB2kZW_e+Qtptwn}@eZ1S7yv2- zG8@IGe+{FeF~>5Z2J}PlM%J#5lzeRS$kUz`H5PRH`2+zLP$GxuMN6zG;(FF&eQ6`c zxw~;-qNZTg)TJ7}UTfae$Afb<)+*m)`WQWE8^I$Cl&dCJ|J1PGj`btInztWS+cy> zNe7%hEqtASzzCK|emK1Jybsx`TM(Um@^RAK8@&F)JWkYAWx_}Qaioe1?ZEI(TwAAl zNy}&Dw|p+Kq)!SNP|vBwsj$0^6)^sAsq*frOqrpd6EH3ogW4S=DFSK`8Ap0h1#?=~ zio5~RiBkzO)H7Afj0i2QB;Nc`IBKLP4h+Mm0UIrWZb z&C~@;PqgZb@5TuD*Ae4c8vWf*W3d!(x4uxN)h50iy*xE$Cx$sIN$3lG%ov*eLZD|2 zZK6u4`Q@x7NWxk{5e;B!`?*vdnPy1uqPbx_UA_rd_^N!CpXWR&p3Y)tra{Obs?KQ_ zFo7%Jdyhi#`&zPMZ!J}iznncBjAJ_G^t)SI+pD6AUbmyXh$nZSGlRXUSe5)(VeJxJ z<2!=ueVVLo)4!-H?)%QyYTlGQOZIfAd$; z9ce)APuG@ehbPAj;g&edp9V(B;;EQbQJ#g4y0v;u27GUX*hTbmffed4i0eh`TJUf+=P;oy%8Ftsu<7rOyH zWYeO)(cCO4Q?xX&XGwfA(#}-SmcEb$9(Wafwa~qDEXM_6jK!L}FWvu4Eel06Hoi_s zK~gr+az5=-IKixtS-kob46&AM$Y64av-~<`){}WtP;U0OM*`#4ZELTl9}NBj0Caz@ zGNOW>Diwz+cN4a#u+%2A9WCl3BW0CUJ zp$LJfA6n&WM-CQ8l&N+qXXzXLDGRKAR@vF8TB;}Gj8JSE%ld3y@U)&b)f?ezezG5B zS42DLTB07@%PmlQ?q{0&v*BuguG*zDw{=u`g6=Ei7l@YPAmvkD{8T0?!SzOy*7S7~ z!~4~N(DYJSeVMu&RZoZaMIu<)JJ0%*Vg|>{26Ebx{*Wzq{RiH0{Ubx#&LnG1V6^X3 zZyltnX5*_5_h`{q%%E%0DVk2%YIf_z>2PC>80vnKg?v6j@%MzTxgA%BS`+w&u05=Z~oDt!*ON-ZCUE9NjE&fR^;W~*9zuY=HS>MPj$kAjU2FM)idy9h$P=4LeyXOR5|T;r=0d*uGj^g2MB{t z_(n|>l1d+wudL(81w{|1f)@go}R`%C1 zx7RZ_KNDGf2*Dn;PuM6TNO-iZemEF&nF&(>nr`xy&`)L6^{$C4Mk!r>P2SVz3j3-6h;O22BOh^6l8!R5v5 z%)doTXMVU~&WvrBT$IU_>+ra6EojWgW{%}F7e3tyo_AQ!%%$<$-Q}Sw(d)L4%;X`g z`4^-b1r1_`@W{09gXyE08Vptk1jBu>i`j(nZ$RLTtzB44rdP7A)|wG zFoRcd1-#_IB4&m=QGDEEX5n^qL{M=waI-vk*sNm&D=84hm~0nL1pR;pmjBm4n|mHX zNPBjJA#f->n|mtVELYkIqdwAn+EJB!#7nlzCW3mRQ7D&Y7Y&%8#1XGlqYJR-I_Qhe z)DSvK)(tR|JQgELkvJ|1&(`=98ZpnQ-3(Jn5kJB{^h-An#=?Wpz|P5VQ7NkXVX~Ji zqGYDgSi2G=^*W{0MXct4_w{7=x0nJ?8UiS|0#S6()?Rm6Zt*#+zg%;~VzX(&Gih-G z7jGuf2Y$TqMnMLvYY5~}JG6ZfFhJYcLjVcC;r=2(COqrxRk+GES6a=Zj*^6#S0$|j zlhZkt)?u78sT}fQd+EiL`X?T_4o=h#CvxBx$+44&n`aCA0_pi3x%ngMd4@TvlL+AR z&gJi&XFBzGBK2w{^&BNxsg7sMV5zesGKaN12Vc<{?=J$G55tSESJG1A>~m+H#mAn7 ztDJK^n|Q|*HH(FML=Ihf4qV=aol2#hY7y*!4)cf-I_QXoz%!M@eY}91(>Kz{MHvTN zOaTcE0W&m;0wphHd|VV*5frGvaYLse#+MQ!@PPEPK14c39!oBw1FzkiFDTbX z#DTYY<)AtMOg!W>ofq7}GYaDn`ZlJ7zsoXG%RMzyP$VTA8h12$ z>B)z2yVoBhDf_owUg{5h4F9eD(e!_mQEvC7(u8FT(zXJ)S8ZH6tvMI#Vvlxz_N20@ zhiq$=&3)=p6gsDpcI92p9doAj=~Te8_cqK1D}>UNfal&nB>oY$y&bJQUsAMN63i}% znH!D!2)HL1JZk(pYke_09Uy6FmB$Lrp37y0GNi#J>Jmn4314!`L#GcLA*y4Gi2>P5=g^K}kQm5-&@D;F~gCZ|?qu0-pkr|epq1An@Ft=2!7M5Kr`?fcz^ z1)h;IaUdpAPf|%_HQU?leQ=Zhb)R%Lu)4ZsL$yx8-R80Oef=jzZ;0#S7xpp_=+3i% zALx?>(@*Cd(~t9;<~z`OSG+)Mbr)I=ZpW=gZj+ha(+54l?59l?*TqCWfYdY@*LDDA zyxuav(8%qAgSA$4MSq=tKjCMqS!gK8K=05($H?*3!gO}(_YZB&&i2Y%UDE3AUS4P2 zqx7;eq(!KV{N`pypap2mJfF;w;%qKwzgVe5RLi%prj^_8)B&>NW1aoE)+Z>%d@=1C zm97SZ(N#_E9wU|@{+1;*x6#!hD0BSt9x1wp5{{@b&QluFy}Htp=?Y39Dv0Kh{0jKK z)6=96YDfr`;sd+!j{-8>yaq@n+OrRS25q32`wVKDe9K2gacp46!7XD{-4njM7kG50 z2x7zuNk?@nAN1EL^uaWdM4`#1leYQMd;OHj>h6vCa!CA+TCg-C7uTR{SPC*+KW8Zu zeWTrz?Yq6T_LH5yY-B1b@Py(N$pvj9^=vhNfRVX-z-t$L{civrb{Mb`P@~B=VvA@Q03>F-CzOH-h#|8g|`O=22y5&EcayLFeC z28QmGXIbz%&fgP_Hl>x04kdq)2oV=F@|iDcVz;Rq@~X;2MWQ+3`AM2b4@7+u zGF&y+Y~fGp3`LZ{a-C3V3RwXhup3?7aXcn~PR=?$aOGmEmTS~}8A0i{_tT7e)q&0E zKE_o3+X$}Ux?}mMnHmxOp@-W-@Q?^jeJHDX^4NPkKElm+6#3x z+n408E5*@D2qjTnmK~$WYFepCQ(thl2M3Sc+uOPF}?%Mf9 zq6$Xm4H-tO^9RSj8Lj}QN)?`o15tZ;BPG}|zR=+wzO8W9U720EUGMeEvUUB)Cbr6H zpX=U!7Kx_02mBhj$q!xLq;lkPuAcjvu{J~<6^P275Ha$E626=G!vQ88yxIqLAC-P} zUy&j{1x%^FwNhzNjBSKK2{a5H3U-!#=GiQIr`N1j>)CiE%D>2tyv?O9DUm80w+ar( z2u|^-j;ps?q0nc98kms_5x~m*^?G8jxW~gy|F_SzSce7uq!-+>hz*U%*rZi zzMqbBvZqT#ZS9IDhbo+PY2wX(WwGEHnhnEOLfVqPPFJ4MC*Q&tQ?oW{mXTH`>7RtDQyo{wH_Oi zDxW_M+Y0$)LL=Gjha~u!+bl0ST2=UA1@=Ry{89JzT{`jN^OnlnCLTR0OiO%3PBNBk zwjW5+3xBK9UiLFqTkSPgII-m{e9HrUTqjoT>-gzApKCO_k;cmc{AgR#Q6eO2{gRM~ zE7G2XBT^egSi;Ki{THN{F&q%?=}dpRhbJEs65LaCw0O)cO#B+~yPGNw0#*L|trRNW zI2p;DrgEuv(muQ3R<2srlsbv(!xWe-dtZ!E5?;C*(QI;PUVtEEJ3vkDZjLIRf?z%# zc4;%^=nArTDkt+^4N> zP0sqaXxb-2OM0`&a};wfJZs0Synm{6^y+e<$+nw*G=|r6dO=)Vb8+j8+C+pmFWij&U#1lSPrRE4 z+8j6H>p^3D2H(GMdX>4jwn8)vR^hf8ooj-$t&@PZC-w;wV9x2`XJDQSDyLVk1nX&ND1@sAKqfdH5r4>-bCv76DK0FIN0W;tm5ZjCkHejZ;{_kSf8ybxt;;TOwzFP4 zgb%;oz$kBE?KhUc5yy<)ka34mk_{}A4&afZ^XTkZ{0T&OaYUFjLeC)`BEf>)V8NcE zcx(xXxjz4vN1Xg8u$OdNWP^+GJG7*O1*C&RBU;^Wqe+L6;$h<>Q_Z+Wb&gV>u%(Q# z>$#)T52YtMdEB|EM9A{R$mTKzh8PhiUxc%I7`2*^eYwIXGNhr}TA4%Vs<3}*olwRT znEj?%7}xHVZ>0m9B2&hjJSc!X$e+BY3!9WHrt6@=uk%f_0Ir>bh)Eljt2yqr2`)QB zMaDa}!C=CkcY~qIh?D*vJmiOiw>K8&{tbKL9YH7?-H3hth;#ZM230DISqEwoBaV$Q z!qeUVkuiwz#hs+54RfhBdb1kVwdg+WgkTp;HMv&}tFDGISHBO&!LASQUK)5<8pr_- zG?TI|Ve;e)XT>jlnEv!Q_6cOciCr5Zwlp|@x|87J{wH9i&n#>_KH}I6?|FH+>=gFr z?yBI_5{|mkh47hjCm-i(1r-6pf(y^axq3l;d_^@3lH{0(zIJ{)7EQl;_38`)7>BXO zM|k+Zn@3`om^nj$BE?8t0q|b7khF1$572px_bfhn74oB@dhSe7T{%a#CAg0$fNbbr zg6LmtOWx?vpz$-4k?wy8;WA0X(u5DGcJfHz2_A-Lpv7DxXPpRViQVid(THcG)x!s` zgDS>iEF?Gb@Dy)g2SErBNnChsP?q+a39Q1tHy4~?Ze#<0+22&+`x1Zd*9vn?jOZ-J zuVal2#DWE0zH}F(SH-_GK`vnbd>FTrPKRKpNQFJJ!xc?3GNX%Kz#V-_Jp7k<*q?F6 z46&CHQO%xevWT#w4O!A0aXM{4q^zNp_;Z=NA1f7SRHiCQmc*~Mw4pRULo?JnTYT0C z^OPXkLz@boLjx?u`ha58^M0MQzV*Y zj3<9rxG>Clca;|aYBU>oG8CUGIEDQ^v9v@=6B_t~+R=nS0}RWg+&NP)+-BJcqQhmb zZX!6LlyQt4PPme$e*x0>xSDP=n1t$V+>vjpH}qq0xqN=k+3DT|6vXwzTbV%J7*meX zRdz=%q;;K)7vRcv!C_PA6^Nh8C3+cbX^G|Rgb<3gu|{VR#6+`qObTj430X&Uto^o$ znNW-G6eacvcC3%Emk9dWZv4I%r&N%|HR9F9-Jfz}9aV9C=UfnB&lSP?3`gZp;zH9C zl^eI;IHCDF3K$DS!N?HjvkY#>6`K#O%wkLi{V7ddgTWguS(4 z29Go&`olABbgGSnWbpjRU*2SJ^LpN*c)tWAAW+p-Q!l&R&96yfe>r#M?8=cnz%LMS z7A^Hlh?i5bP@0nur+@4DbBfDc&+ka4bxmVvf4d~?Ke@9M!8b$JaDPTr1H9g7ZFbX#H@`whpPuUi()i#{X-9 z0RuTSa!JS-^9Yt)m-qT)z>)OW_-X8<;t3GUvJ|bTSjB!iS-2tPSq%A|-dKqM%$Me` z!8K+n1*t2yBmQJduG2L03Tpc36Jr0lqUv9?!sceL^0AfQ#4u;sFnvA!j6UMOrv+bv z%j^%O67Du?@i7#=%@=^5UVU1HY!+=BXC~T~-G0!7cH_~i=Gm#4;XbC`%|c^?ZrurK zCGx;YYY9`*dMyDK?YaBop;@@5YmO2+tJ+HKn+(tU4cYvzVKx#Ykk_-S_XVOAzl)pf zlz?YOqOug2M=1t0mpVSqYjlOi*)Ao~bU1A93uqdDkH69Y(p;pXmtYt;k((Ju;LU~v zyDC0x;K$Y)qv>+H+&;~t)7T!V(9GtC8{e`e%c**uKnlDJ#T14Zk6O0ONhRIJstFkp zeTk%ng(`q9bz4YwkDM-Z4pPA1I^+~{u-x^!c5088L&L;jY6MzNs*S6i$qqDmx}{#;jfYvrp#GtFt!Q<14u6vW8ZmjvK5mvti)VLONM3oQ#-+R`yVk<9!1X*(8 zh;?y*{%xjhW{jnB3VaElOtnmx&jf|6)T|!PY^>pxa=bU5DOpcIC*FBu-@?Xb?wOfP z29MSK1mHht%&kLq67&|SKE6~@h89&NYQgfOhul-z4eQ3@xNJ))9s7YzYb0VbwTddq zuln4-R>D_eKkktkY~<0WtAbta-EgV{lo~xR*3L61_=c(gIK&B1p;dY+;1z!^C#y)W zNb0h^h!NUW%2;?3lgAE!rO1`EI>WBcI;MDcitts$H(NhlR`mo`mcNpnyHL40!$_MbcK%Oan4zkFe37r%u!BkHk9qt=1wY^h;(cO9LY({|)C! zQzHw8zLxCy`l`wQZ(ZK)vW9W-*aHFd`{?9SqwrKQ^(a8osq10|PyvpET@vVE#dS?J z`n39TP_FZy3H;A4-?6cy|KLgWGq7D%Gf3fAV+A@y-#KYt^ zoZMNnm|ikxjKExZ_X}+q9G$VLT(rOG`>A>xL%_AdX6jMwWIPebYH?&LpQT@zQL_bm z`8-kJlFLE!`u8EyA}e*jwGUfFdbk|oc9z@@nhsIN}F4pT4x9-AtfbS)*E|dXs?V^_G8r4S4aHuwu3O669p}V~ej8V)rXN zmARn^zG;?;Ed`Zlq=#qq+UKV3Xo-OGMWY~C=?5AuJ+>5aizr~iY~iPj7aZ+v5r z?r>B*2A}_|Oug-71ibCXJNm`6IVyq5jPLA$AOTs;!v#y+xdMPQ0e*nTu77h#Y61K@ zCDS}|-JQ@JW~42t#fx9{#N(840B%x`b|OdJw=vfKUqCu!I5ES>3VhHh(NKXV_z>34 zJl;1?RQSJB^j*QFW3Qfu0W4dyZ@q|66K1eKu7G{KKuoWDJQ>ZykSZM%kr^C?D-bUS zHj)%xM`M&+IV5tJ@+cNr4!4U{@){PKDYzcY47#GFgYq$h4{!xil=x-CJ28n_8w>~> z^#75$cj3R7ImjejlkG|#C4_CR#-rHipTTRCX2WAdLRhYbqe8;%B8Z?*xB{zmP@fRt zvl#A27&D!EA`w&p4Q!IkR83}g_rZ+&?3l#iI?n-<$^laz3=D)tn}JHn{+O1s`WCZ# z6ta>yZXje4ge4#xVWJD<iNh)QXvcGRivV<1V$ySWA7*#*&=g;)W{;BSj)hYy2OlHriSSjcQRM7dOC z0d4tyaQklXK^{!X4Aw^j3noX0xIYa44q2Jyo@o)AJ@YO+{`b~pO!YG?JOrd)x_Tft ze<0m#TO%uFvK_iBruxQitHHki zJ7m0=oicozW5lErZ?Rz9_Id6(;?oBCm~Xi4aRM^T;BQC?TysIHG37oWoftjKaJzw6 zrd?_4F9PS5E7Z}a!&fNDOeb>uN7DQZb3Me-215-FZ`$zC1%@>Q(5M}_2pm5kS>O)5 zBDw39c235^>HpFCsDv=mH`4GSbQ59%-$hY|i%9?t`*{gf0n53H9ORVVT?d&aejuK1 zelbyPS(w+9a#Ub3Y!!8%a>fN+ZCQlR^z!@dFJVX$xqAsh62+Bu@-eC+gfcG+H(pJL zPd&@=LpEze5MRi07nb@r^^T#)K=nsOqWs{)_JW+NXbh~98In9Ll(J2#QIIk*t~{)$ zV&L&u_y_A>(kR+KuRfrb@WaxhmjUk3Z0Zu@jg}&r4rg~OWw#{uW1@(w>4+<1 zf?rdh??$rj_cLy6+zS2mAFu%H?4pFENDnN2znhmy9_zoPITBYXAaM zlWafjvhhpGD&0@24@ZzZq%ls&2vK&_B#4|^9|D;z7_ESKkM)*FX^_8qICbA8XSb@u zkBV*^t#lKD<#_@)tZ%adZ=3V+G7RliydVPhD*PB_{z3aJs%BThckM%lB3;{S(1Pm&QKCJLI!^Aafh%cK}N^y=sUe zUdO1bn3-^Q9DRAoNAr#KO5Odqq?Csip{c{$bo0}lmb!~Zr zp^s@q`W<{KWv(j1rBy<9tHR{;?jgoc&j(j9J$(spiR8S-jpHsYFh@nxwy|rSa#$hC z%;=M-)+Uwi!e&DEKaG1i@K1&ea`h^6#YI*l*`odY^z7OGC$vED)-CMsn>YUjW?lIC diff --git a/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts b/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts index 25c79cece1..90e2a0947f 100644 --- a/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts +++ b/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts @@ -5,6 +5,7 @@ import { createModule } from '../../../__tests__/create-module'; import { Mockers } from '../../../__tests__/mocks'; import { Models } from '../../../models'; import { + parseDocToMarkdownFromDocSnapshot, readAllBlocksFromDocSnapshot, readAllDocIdsFromWorkspaceSnapshot, } from '../blocksuite'; @@ -88,3 +89,13 @@ test('can read all blocks from doc snapshot without workspace snapshot', async t blocks: result!.blocks.map(block => omit(block, ['yblock'])), }); }); + +test('can parse doc to markdown from doc snapshot', async t => { + const result = parseDocToMarkdownFromDocSnapshot( + workspace.id, + docSnapshot.id, + docSnapshot.blob + ); + + t.snapshot(result); +}); diff --git a/packages/backend/server/src/core/utils/blocksuite.ts b/packages/backend/server/src/core/utils/blocksuite.ts index 82c627d12d..ff3f64597c 100644 --- a/packages/backend/server/src/core/utils/blocksuite.ts +++ b/packages/backend/server/src/core/utils/blocksuite.ts @@ -8,6 +8,7 @@ // eslint-disable-next-line @typescript-eslint/no-restricted-imports -- import from bundle import { + parsePageDoc as parseDocToMarkdown, readAllBlocksFromDoc, readAllDocIdsFromRootDoc, } from '@affine/reader/dist'; @@ -196,3 +197,30 @@ export async function readAllBlocksFromDocSnapshot( maxSummaryLength, }); } + +export function parseDocToMarkdownFromDocSnapshot( + workspaceId: string, + docId: string, + docSnapshot: Uint8Array +) { + const ydoc = new YDoc({ + guid: docId, + }); + applyUpdate(ydoc, docSnapshot); + + const parsed = parseDocToMarkdown({ + workspaceId, + doc: ydoc, + buildBlobUrl: (blobId: string) => { + return `/${workspaceId}/blobs/${blobId}`; + }, + buildDocUrl: (docId: string) => { + return `/workspace/${workspaceId}/${docId}`; + }, + }); + + return { + title: parsed.title, + markdown: parsed.md, + }; +}