feat: update page content

This commit is contained in:
Philipinho
2026-01-16 22:18:52 +00:00
parent bcb004af21
commit c93ea6cfc9
6 changed files with 119 additions and 9 deletions
@@ -64,6 +64,10 @@ export class CollaborationGateway {
return this.hocuspocus.getDocumentsCount(); return this.hocuspocus.getDocumentsCount();
} }
openDirectConnection(documentName: string, context?: any) {
return this.hocuspocus.openDirectConnection(documentName, context);
}
async destroy(): Promise<void> { async destroy(): Promise<void> {
await this.hocuspocus.destroy(); await this.hocuspocus.destroy();
} }
@@ -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/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import * as Y from 'yjs';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@@ -116,3 +117,32 @@ export function jsonToNode(tiptapJson: JSONContent) {
export function getPageId(documentName: string) { export function getPageId(documentName: string) {
return documentName.split('.')[1]; 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<string, any> = {};
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;
}
@@ -179,7 +179,7 @@ export class PersistenceExtension implements Extension {
async onChange(data: onChangePayload) { async onChange(data: onChangePayload) {
const documentName = data.documentName; const documentName = data.documentName;
const userId = data.context?.user.id; const userId = data.context?.user?.id;
if (!userId) return; if (!userId) return;
if (!this.contributors.has(documentName)) { if (!this.contributors.has(documentName)) {
@@ -1,8 +1,18 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto } from './create-page.dto'; 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) { export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString() @IsString()
pageId: string; pageId: string;
@IsOptional()
@IsString()
content?: string;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['append', 'replace'])
contentMode?: ContentMode;
} }
+2 -1
View File
@@ -4,11 +4,12 @@ import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service'; import { TrashCleanupService } from './services/trash-cleanup.service';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({ @Module({
controllers: [PageController], controllers: [PageController],
providers: [PageService, PageHistoryService, TrashCleanupService], providers: [PageService, PageHistoryService, TrashCleanupService],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService],
imports: [StorageModule], imports: [StorageModule, CollaborationModule],
}) })
export class PageModule {} export class PageModule {}
@@ -5,7 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePageDto } from '../dto/create-page.dto'; 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 { PageRepo } from '@docmost/db/repos/page/page.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
@@ -28,7 +28,13 @@ import {
isAttachmentNode, isAttachmentNode,
removeMarkTypeFromDoc, removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils'; } 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 { import {
CopyPageMapEntry, CopyPageMapEntry,
ICopyPageAttachment, ICopyPageAttachment,
@@ -40,6 +46,10 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants'; import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter'; 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() @Injectable()
export class PageService { export class PageService {
@@ -53,6 +63,7 @@ export class PageService {
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
private collaborationGateway: CollaborationGateway,
) {} ) {}
async findById( async findById(
@@ -167,6 +178,15 @@ export class PageService {
page.id, page.id,
); );
if (updatePageDto.content && updatePageDto.contentMode) {
await this.updatePageContent(
page.id,
updatePageDto.content,
updatePageDto.contentMode,
userId,
);
}
return await this.pageRepo.findById(page.id, { return await this.pageRepo.findById(page.id, {
includeSpace: true, includeSpace: true,
includeContent: true, includeContent: true,
@@ -176,6 +196,46 @@ export class PageService {
}); });
} }
async updatePageContent(
pageId: string,
markdown: string,
mode: ContentMode,
userId: string,
): Promise<void> {
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( async getSidebarPages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -259,7 +319,7 @@ export class PageService {
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIds, pageId: pageIds,
workspaceId: rootPage.workspaceId workspaceId: rootPage.workspaceId,
}); });
} }
}); });
@@ -387,9 +447,14 @@ export class PageService {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
creatorId: authUser.id, creatorId: authUser.id,
lastUpdatedById: authUser.id, lastUpdatedById: authUser.id,
parentPageId: page.id === rootPage.id parentPageId:
? (isDuplicateInSameSpace ? rootPage.parentPageId : null) page.id === rootPage.id
: (page.parentPageId ? pageMap.get(page.parentPageId)?.newPageId : null), ? isDuplicateInSameSpace
? rootPage.parentPageId
: null
: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId
: null,
}; };
}), }),
); );