From 887ef380989226f758cefbf11a830cfd1a6ab3f4 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 16 Jan 2026 23:53:35 +0000 Subject: [PATCH] feat: support creating page with content --- .../src/collaboration/collaboration.util.ts | 6 ++ .../src/core/page/dto/create-page.dto.ts | 17 +++- apps/server/src/core/page/dto/page.dto.ts | 11 ++- .../src/core/page/dto/update-page.dto.ts | 13 +-- apps/server/src/core/page/page.controller.ts | 15 ++++ .../src/core/page/services/page.service.ts | 88 ++++++++++++++++--- 6 files changed, 130 insertions(+), 20 deletions(-) diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 6386fbc38..e20dabdb1 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -44,6 +44,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; // see:https://github.com/ueberdosis/tiptap/issues/4089 import { Node } from '@tiptap/pm/model'; import * as Y from 'yjs'; +import { turndown } from '../integrations/export/turndown-utils'; export const tiptapExtensions = [ StarterKit.configure({ @@ -146,3 +147,8 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText { } return element; } + +export function jsonToMarkdown(tiptapJson: any): string { + const html = jsonToHtml(tiptapJson); + return turndown(html); +} diff --git a/apps/server/src/core/page/dto/create-page.dto.ts b/apps/server/src/core/page/dto/create-page.dto.ts index 397e4a424..601c98daa 100644 --- a/apps/server/src/core/page/dto/create-page.dto.ts +++ b/apps/server/src/core/page/dto/create-page.dto.ts @@ -1,4 +1,12 @@ -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { + IsIn, + IsOptional, + IsString, + IsUUID, + ValidateIf, +} from 'class-validator'; + +export type InputFormat = 'json' | 'markdown' | 'html'; export class CreatePageDto { @IsOptional() @@ -15,4 +23,11 @@ export class CreatePageDto { @IsUUID() spaceId: string; + + @IsOptional() + content?: string | object; + + @ValidateIf((o) => o.content !== undefined) + @IsIn(['json', 'markdown', 'html']) + input?: InputFormat; } diff --git a/apps/server/src/core/page/dto/page.dto.ts b/apps/server/src/core/page/dto/page.dto.ts index e897d3a5b..45a1d2ae3 100644 --- a/apps/server/src/core/page/dto/page.dto.ts +++ b/apps/server/src/core/page/dto/page.dto.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsNotEmpty, IsOptional, IsString, @@ -22,14 +23,20 @@ export class PageHistoryIdDto { historyId: string; } +export type OutputFormat = 'json' | 'markdown' | 'html'; + export class PageInfoDto extends PageIdDto { @IsOptional() @IsBoolean() - includeSpace: boolean; + includeSpace?: boolean; @IsOptional() @IsBoolean() - includeContent: boolean; + includeContent?: boolean; + + @IsOptional() + @IsIn(['json', 'markdown', 'html']) + output?: OutputFormat; } export class DeletePageDto extends PageIdDto { 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 60c609f8d..8c59f8792 100644 --- a/apps/server/src/core/page/dto/update-page.dto.ts +++ b/apps/server/src/core/page/dto/update-page.dto.ts @@ -1,18 +1,21 @@ import { PartialType } from '@nestjs/mapped-types'; -import { CreatePageDto } from './create-page.dto'; +import { CreatePageDto, InputFormat } from './create-page.dto'; import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator'; -export type ContentMode = 'append' | 'replace'; +export type ContentOperation = 'append' | 'replace'; export class UpdatePageDto extends PartialType(CreatePageDto) { @IsString() pageId: string; @IsOptional() - @IsString() - content?: string; + content?: string | object; @ValidateIf((o) => o.content !== undefined) @IsIn(['append', 'replace']) - contentMode?: ContentMode; + operation?: ContentOperation; + + @ValidateIf((o) => o.content !== undefined) + @IsIn(['json', 'markdown', 'html']) + input?: InputFormat; } diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 972754403..20032ddea 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -35,6 +35,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { RecentPageDto } from './dto/recent-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto'; +import { + jsonToHtml, + jsonToMarkdown, +} from '../../collaboration/collaboration.util'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -66,6 +70,17 @@ export class PageController { throw new ForbiddenException(); } + if (dto.output && dto.output !== 'json' && page.content) { + const contentOutput = + dto.output === 'markdown' + ? jsonToMarkdown(page.content) + : jsonToHtml(page.content); + return { + ...page, + content: contentOutput, + }; + } + return page; } diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 1a457c56f..14f7d316a 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -4,8 +4,8 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { CreatePageDto } from '../dto/create-page.dto'; -import { ContentMode, UpdatePageDto } from '../dto/update-page.dto'; +import { CreatePageDto, InputFormat } from '../dto/create-page.dto'; +import { ContentOperation, 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'; @@ -99,7 +99,42 @@ export class PageService { parentPageId = parentPage.id; } - const createdPage = await this.pageRepo.insertPage({ + let content = undefined; + let textContent = undefined; + let ydoc = undefined; + + if (createPageDto?.content && createPageDto?.input) { + let prosemirrorJson: any; + + switch (createPageDto.input) { + case 'markdown': { + const html = await markdownToHtml(createPageDto.content as string); + prosemirrorJson = htmlToJson(html as string); + break; + } + case 'html': { + prosemirrorJson = htmlToJson(createPageDto.content as string); + break; + } + case 'json': + default: { + prosemirrorJson = createPageDto.content; + break; + } + } + + try { + jsonToNode(prosemirrorJson); + } catch (err) { + throw new BadRequestException('Invalid content format'); + } + + content = prosemirrorJson; + textContent = jsonToText(prosemirrorJson); + ydoc = createYdocFromJson(prosemirrorJson); + } + + return this.pageRepo.insertPage({ slugId: generateSlugId(), title: createPageDto.title, position: await this.nextPagePosition( @@ -112,9 +147,10 @@ export class PageService { creatorId: userId, workspaceId: workspaceId, lastUpdatedById: userId, + content, + textContent, + ydoc, }); - - return createdPage; } async nextPagePosition(spaceId: string, parentPageId?: string) { @@ -178,11 +214,16 @@ export class PageService { page.id, ); - if (updatePageDto.content && updatePageDto.contentMode) { + if ( + updatePageDto.content && + updatePageDto.operation && + updatePageDto.input + ) { await this.updatePageContent( page.id, updatePageDto.content, - updatePageDto.contentMode, + updatePageDto.operation, + updatePageDto.input, userId, ); } @@ -198,12 +239,35 @@ export class PageService { async updatePageContent( pageId: string, - markdown: string, - mode: ContentMode, + content: string | object, + operation: ContentOperation, + input: InputFormat, userId: string, ): Promise { - const html = await markdownToHtml(markdown); - const prosemirrorJson = htmlToJson(html as string); + let prosemirrorJson: any; + + switch (input) { + case 'markdown': { + const html = await markdownToHtml(content as string); + prosemirrorJson = htmlToJson(html as string); + break; + } + case 'html': { + prosemirrorJson = htmlToJson(content as string); + break; + } + case 'json': + default: { + prosemirrorJson = content; + break; + } + } + + try { + jsonToNode(prosemirrorJson); + } catch (err) { + throw new BadRequestException('Invalid content format'); + } const documentName = `page.${pageId}`; const connection = await this.collaborationGateway.openDirectConnection( @@ -215,7 +279,7 @@ export class PageService { await connection.transact((doc) => { const fragment = doc.getXmlFragment('default'); - if (mode === 'replace') { + if (operation === 'replace') { while (fragment.length > 0) { fragment.delete(0, 1); }