diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 3f894572..53afd64a 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -64,6 +64,10 @@ export class CollaborationGateway { return this.hocuspocus.getDocumentsCount(); } + openDirectConnection(documentName: string, context?: any) { + return this.hocuspocus.openDirectConnection(documentName, context); + } + async destroy(): Promise { await this.hocuspocus.destroy(); } diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 06133c3f..6386fbc3 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -43,6 +43,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // see: https://github.com/ueberdosis/tiptap/issues/5352 // see:https://github.com/ueberdosis/tiptap/issues/4089 import { Node } from '@tiptap/pm/model'; +import * as Y from 'yjs'; export const tiptapExtensions = [ StarterKit.configure({ @@ -116,3 +117,32 @@ export function jsonToNode(tiptapJson: JSONContent) { export function getPageId(documentName: string) { return documentName.split('.')[1]; } + +export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText { + if (node.type === 'text') { + const ytext = new Y.XmlText(); + ytext.insert(0, node.text || ''); + if (node.marks?.length > 0) { + const attrs: Record = {}; + for (const mark of node.marks) { + attrs[mark.type] = mark.attrs || true; + } + ytext.format(0, node.text?.length || 0, attrs); + } + return ytext; + } + + const element = new Y.XmlElement(node.type); + if (node.attrs) { + for (const [key, value] of Object.entries(node.attrs)) { + if (value !== null && value !== undefined) { + element.setAttribute(key, value as any); + } + } + } + if (node.content?.length > 0) { + const children = node.content.map(prosemirrorNodeToYElement); + element.insert(0, children); + } + return element; +} diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 54c4a89e..9647dbb1 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -179,7 +179,7 @@ export class PersistenceExtension implements Extension { async onChange(data: onChangePayload) { const documentName = data.documentName; - const userId = data.context?.user.id; + const userId = data.context?.user?.id; if (!userId) return; if (!this.contributors.has(documentName)) { diff --git a/apps/server/src/core/page/dto/update-page.dto.ts b/apps/server/src/core/page/dto/update-page.dto.ts index 7bd2e2a6..60c609f8 100644 --- a/apps/server/src/core/page/dto/update-page.dto.ts +++ b/apps/server/src/core/page/dto/update-page.dto.ts @@ -1,8 +1,18 @@ import { PartialType } from '@nestjs/mapped-types'; import { CreatePageDto } from './create-page.dto'; -import { IsString } from 'class-validator'; +import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator'; + +export type ContentMode = 'append' | 'replace'; export class UpdatePageDto extends PartialType(CreatePageDto) { @IsString() pageId: string; + + @IsOptional() + @IsString() + content?: string; + + @ValidateIf((o) => o.content !== undefined) + @IsIn(['append', 'replace']) + contentMode?: ContentMode; } diff --git a/apps/server/src/core/page/page.module.ts b/apps/server/src/core/page/page.module.ts index 9dfba84a..1d70d9fa 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -4,11 +4,12 @@ import { PageController } from './page.controller'; import { PageHistoryService } from './services/page-history.service'; import { TrashCleanupService } from './services/trash-cleanup.service'; import { StorageModule } from '../../integrations/storage/storage.module'; +import { CollaborationModule } from '../../collaboration/collaboration.module'; @Module({ controllers: [PageController], providers: [PageService, PageHistoryService, TrashCleanupService], exports: [PageService, PageHistoryService], - imports: [StorageModule], + imports: [StorageModule, CollaborationModule], }) export class PageModule {} diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 9bfb5e1c..1a457c56 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { CreatePageDto } from '../dto/create-page.dto'; -import { UpdatePageDto } from '../dto/update-page.dto'; +import { ContentMode, UpdatePageDto } from '../dto/update-page.dto'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; @@ -28,7 +28,13 @@ import { isAttachmentNode, removeMarkTypeFromDoc, } from '../../../common/helpers/prosemirror/utils'; -import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; +import { + htmlToJson, + jsonToNode, + jsonToText, + prosemirrorNodeToYElement, + tiptapExtensions, +} from 'src/collaboration/collaboration.util'; import { CopyPageMapEntry, ICopyPageAttachment, @@ -40,6 +46,10 @@ import { Queue } from 'bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { EventName } from '../../../common/events/event.contants'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CollaborationGateway } from '../../../collaboration/collaboration.gateway'; +import { markdownToHtml } from '@docmost/editor-ext'; +import { TiptapTransformer } from '@hocuspocus/transformer'; +import * as Y from 'yjs'; @Injectable() export class PageService { @@ -53,6 +63,7 @@ export class PageService { @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, private eventEmitter: EventEmitter2, + private collaborationGateway: CollaborationGateway, ) {} async findById( @@ -167,6 +178,15 @@ export class PageService { page.id, ); + if (updatePageDto.content && updatePageDto.contentMode) { + await this.updatePageContent( + page.id, + updatePageDto.content, + updatePageDto.contentMode, + userId, + ); + } + return await this.pageRepo.findById(page.id, { includeSpace: true, includeContent: true, @@ -176,6 +196,46 @@ export class PageService { }); } + async updatePageContent( + pageId: string, + markdown: string, + mode: ContentMode, + userId: string, + ): Promise { + const html = await markdownToHtml(markdown); + const prosemirrorJson = htmlToJson(html as string); + + const documentName = `page.${pageId}`; + const connection = await this.collaborationGateway.openDirectConnection( + documentName, + { user: { id: userId } }, + ); + + try { + await connection.transact((doc) => { + const fragment = doc.getXmlFragment('default'); + + if (mode === 'replace') { + while (fragment.length > 0) { + fragment.delete(0, 1); + } + const newDoc = TiptapTransformer.toYdoc( + prosemirrorJson, + 'default', + tiptapExtensions, + ); + Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc)); + } else { + const newContent = prosemirrorJson.content || []; + const yElements = newContent.map(prosemirrorNodeToYElement); + fragment.insert(fragment.length, yElements); + } + }); + } finally { + await connection.disconnect(); + } + } + async getSidebarPages( spaceId: string, pagination: PaginationOptions, @@ -259,7 +319,7 @@ export class PageService { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { pageId: pageIds, - workspaceId: rootPage.workspaceId + workspaceId: rootPage.workspaceId, }); } }); @@ -387,9 +447,14 @@ export class PageService { workspaceId: page.workspaceId, creatorId: authUser.id, lastUpdatedById: authUser.id, - parentPageId: page.id === rootPage.id - ? (isDuplicateInSameSpace ? rootPage.parentPageId : null) - : (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null), + parentPageId: + page.id === rootPage.id + ? isDuplicateInSameSpace + ? rootPage.parentPageId + : null + : page.parentPageId + ? pageMap.get(page.parentPageId)?.newPageId + : null, }; }), );