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 0000000000..5351944f77
Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap differ
diff --git a/packages/backend/server/src/__tests__/e2e/doc-service/controller.spec.ts b/packages/backend/server/src/__tests__/e2e/doc-service/controller.spec.ts
new file mode 100644
index 0000000000..afa1d12772
--- /dev/null
+++ b/packages/backend/server/src/__tests__/e2e/doc-service/controller.spec.ts
@@ -0,0 +1,42 @@
+import { randomUUID } from 'node:crypto';
+
+import { CryptoHelper } from '../../../base';
+import { app, e2e, Mockers } from '../test';
+
+const crypto = app.get(CryptoHelper);
+
+e2e('should get doc markdown success', async t => {
+ 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 0000000000..d5593e9644
Binary files /dev/null and b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap differ
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 0000000000..d5593e9644
Binary files /dev/null and b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap differ
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 8c97b9a14d..7c77db4c2a 100644
Binary files a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap and b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap differ
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,
+ };
+}