feat: support creating page with content

This commit is contained in:
Philipinho
2026-01-16 23:53:35 +00:00
parent c93ea6cfc9
commit 887ef38098
6 changed files with 130 additions and 20 deletions
@@ -44,6 +44,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// 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'; import * as Y from 'yjs';
import { turndown } from '../integrations/export/turndown-utils';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
@@ -146,3 +147,8 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
} }
return element; return element;
} }
export function jsonToMarkdown(tiptapJson: any): string {
const html = jsonToHtml(tiptapJson);
return turndown(html);
}
@@ -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 { export class CreatePageDto {
@IsOptional() @IsOptional()
@@ -15,4 +23,11 @@ export class CreatePageDto {
@IsUUID() @IsUUID()
spaceId: string; spaceId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['json', 'markdown', 'html'])
input?: InputFormat;
} }
+9 -2
View File
@@ -1,5 +1,6 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
@@ -22,14 +23,20 @@ export class PageHistoryIdDto {
historyId: string; historyId: string;
} }
export type OutputFormat = 'json' | 'markdown' | 'html';
export class PageInfoDto extends PageIdDto { export class PageInfoDto extends PageIdDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeSpace: boolean; includeSpace?: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeContent: boolean; includeContent?: boolean;
@IsOptional()
@IsIn(['json', 'markdown', 'html'])
output?: OutputFormat;
} }
export class DeletePageDto extends PageIdDto { export class DeletePageDto extends PageIdDto {
@@ -1,18 +1,21 @@
import { PartialType } from '@nestjs/mapped-types'; 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'; import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
export type ContentMode = 'append' | 'replace'; export type ContentOperation = 'append' | 'replace';
export class UpdatePageDto extends PartialType(CreatePageDto) { export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString() @IsString()
pageId: string; pageId: string;
@IsOptional() @IsOptional()
@IsString() content?: string | object;
content?: string;
@ValidateIf((o) => o.content !== undefined) @ValidateIf((o) => o.content !== undefined)
@IsIn(['append', 'replace']) @IsIn(['append', 'replace'])
contentMode?: ContentMode; operation?: ContentOperation;
@ValidateIf((o) => o.content !== undefined)
@IsIn(['json', 'markdown', 'html'])
input?: InputFormat;
} }
@@ -35,6 +35,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto';
import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@@ -66,6 +70,17 @@ export class PageController {
throw new ForbiddenException(); 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; return page;
} }
@@ -4,8 +4,8 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePageDto } from '../dto/create-page.dto'; import { CreatePageDto, InputFormat } from '../dto/create-page.dto';
import { ContentMode, UpdatePageDto } from '../dto/update-page.dto'; import { ContentOperation, 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';
@@ -99,7 +99,42 @@ export class PageService {
parentPageId = parentPage.id; 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(), slugId: generateSlugId(),
title: createPageDto.title, title: createPageDto.title,
position: await this.nextPagePosition( position: await this.nextPagePosition(
@@ -112,9 +147,10 @@ export class PageService {
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
lastUpdatedById: userId, lastUpdatedById: userId,
content,
textContent,
ydoc,
}); });
return createdPage;
} }
async nextPagePosition(spaceId: string, parentPageId?: string) { async nextPagePosition(spaceId: string, parentPageId?: string) {
@@ -178,11 +214,16 @@ export class PageService {
page.id, page.id,
); );
if (updatePageDto.content && updatePageDto.contentMode) { if (
updatePageDto.content &&
updatePageDto.operation &&
updatePageDto.input
) {
await this.updatePageContent( await this.updatePageContent(
page.id, page.id,
updatePageDto.content, updatePageDto.content,
updatePageDto.contentMode, updatePageDto.operation,
updatePageDto.input,
userId, userId,
); );
} }
@@ -198,12 +239,35 @@ export class PageService {
async updatePageContent( async updatePageContent(
pageId: string, pageId: string,
markdown: string, content: string | object,
mode: ContentMode, operation: ContentOperation,
input: InputFormat,
userId: string, userId: string,
): Promise<void> { ): Promise<void> {
const html = await markdownToHtml(markdown); let prosemirrorJson: any;
const prosemirrorJson = htmlToJson(html as string);
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 documentName = `page.${pageId}`;
const connection = await this.collaborationGateway.openDirectConnection( const connection = await this.collaborationGateway.openDirectConnection(
@@ -215,7 +279,7 @@ export class PageService {
await connection.transact((doc) => { await connection.transact((doc) => {
const fragment = doc.getXmlFragment('default'); const fragment = doc.getXmlFragment('default');
if (mode === 'replace') { if (operation === 'replace') {
while (fragment.length > 0) { while (fragment.length > 0) {
fragment.delete(0, 1); fragment.delete(0, 1);
} }