diff --git a/apps/server/src/common/helpers/prosemirror/utils.ts b/apps/server/src/common/helpers/prosemirror/utils.ts index babedbed..7c98065a 100644 --- a/apps/server/src/common/helpers/prosemirror/utils.ts +++ b/apps/server/src/common/helpers/prosemirror/utils.ts @@ -1,7 +1,12 @@ import { Node } from '@tiptap/pm/model'; -import { jsonToNode } from '../../../collaboration/collaboration.util'; +import { + jsonToNode, + tiptapExtensions, +} from '../../../collaboration/collaboration.util'; import { validate as isValidUUID } from 'uuid'; import { Transform } from '@tiptap/pm/transform'; +import { TiptapTransformer } from '@hocuspocus/transformer'; +import * as Y from 'yjs'; export interface MentionNode { id: string; @@ -59,7 +64,6 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] { return pageMentionList as MentionNode[]; } - export function getProsemirrorContent(content: any) { return ( content ?? { @@ -107,4 +111,19 @@ export function removeMarkTypeFromDoc(doc: Node, markName: string): Node { const tr = new Transform(doc).removeMark(0, doc.content.size, markType); return tr.doc; -} \ No newline at end of file +} + +export function createYdocFromJson(prosemirrorJson: any): Buffer | null { + if (prosemirrorJson) { + const ydoc = TiptapTransformer.toYdoc( + prosemirrorJson, + 'default', + tiptapExtensions, + ); + + Y.encodeStateAsUpdate(ydoc); + + return Buffer.from(Y.encodeStateAsUpdate(ydoc)); + } + return null; +} diff --git a/apps/server/src/core/page/dto/copy-page.dto.ts b/apps/server/src/core/page/dto/copy-page.dto.ts new file mode 100644 index 00000000..7fec56f6 --- /dev/null +++ b/apps/server/src/core/page/dto/copy-page.dto.ts @@ -0,0 +1,17 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class CopyPageToSpaceDto { + @IsNotEmpty() + @IsString() + pageId: string; + + @IsNotEmpty() + @IsString() + spaceId: string; +} + +export type CopyPageMapEntry = { + newPageId: string; + newSlugId: string; + oldSlugId: string; +}; diff --git a/apps/server/src/core/page/dto/move-page.dto.ts b/apps/server/src/core/page/dto/move-page.dto.ts index 236a4044..92dc2c5c 100644 --- a/apps/server/src/core/page/dto/move-page.dto.ts +++ b/apps/server/src/core/page/dto/move-page.dto.ts @@ -1,4 +1,10 @@ -import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; +import { + IsString, + IsOptional, + MinLength, + MaxLength, + IsNotEmpty, +} from 'class-validator'; export class MovePageDto { @IsString() @@ -15,9 +21,11 @@ export class MovePageDto { } export class MovePageToSpaceDto { + @IsNotEmpty() @IsString() pageId: string; + @IsNotEmpty() @IsString() spaceId: string; } diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 79424509..f8caeb55 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -28,6 +28,7 @@ import { import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { RecentPageDto } from './dto/recent-page.dto'; +import { CopyPageToSpaceDto } from './dto/copy-page.dto'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -237,6 +238,36 @@ export class PageController { return this.pageService.movePageToSpace(movedPage, dto.spaceId); } + @HttpCode(HttpStatus.OK) + @Post('copy-to-space') + async copyPageToSpace( + @Body() dto: CopyPageToSpaceDto, + @AuthUser() user: User, + ) { + const copiedPage = await this.pageRepo.findById(dto.pageId); + if (!copiedPage) { + throw new NotFoundException('Page to copy not found'); + } + if (copiedPage.spaceId === dto.spaceId) { + throw new BadRequestException('Page is already in this space'); + } + + const abilities = await Promise.all([ + this.spaceAbility.createForUser(user, copiedPage.spaceId), + this.spaceAbility.createForUser(user, dto.spaceId), + ]); + + if ( + abilities.some((ability) => + ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page), + ) + ) { + throw new ForbiddenException(); + } + + return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user); + } + @HttpCode(HttpStatus.OK) @Post('move') async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) { diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 5e4553c6..8227a1fe 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -6,7 +6,7 @@ import { import { CreatePageDto } from '../dto/create-page.dto'; import { UpdatePageDto } from '../dto/update-page.dto'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; -import { Page } 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 { executeWithPagination, @@ -21,6 +21,14 @@ import { DB } from '@docmost/db/types/db'; import { generateSlugId } from '../../../common/helpers'; import { executeTx } from '@docmost/db/utils'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; +import { v7 as uuid7 } from 'uuid'; +import { + createYdocFromJson, + getProsemirrorContent, + removeMarkTypeFromDoc, +} from '../../../common/helpers/prosemirror/utils'; +import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; +import { CopyPageMapEntry } from '../dto/copy-page.dto'; @Injectable() export class PageService { @@ -242,6 +250,60 @@ export class PageService { }); } + async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) { + //TODO: + // i. copy uploaded attachments + // ii. update the attachmentId in the prosemirror node + // iii. maintain internal links within copied pages + + await executeTx(this.db, async (trx) => { + const nextPosition = await this.nextPagePosition(spaceId); + + const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, { + includeContent: true, + }); + + const pageMap = new Map(); + pages.forEach((page) => { + pageMap.set(page.id, { + newPageId: uuid7(), + newSlugId: generateSlugId(), + oldSlugId: page.slugId, + }); + }); + + const insertablePages: InsertablePage[] = await Promise.all( + pages.map(async (page) => { + const pageContent = getProsemirrorContent(page.content); + + const doc = jsonToNode(pageContent); + const prosemirrorDoc = removeMarkTypeFromDoc(doc, 'comment'); + const prosemirrorJson = prosemirrorDoc.toJSON(); + + return { + id: pageMap.get(page.id).newPageId, + slugId: pageMap.get(page.id).newSlugId, + title: page.title, + icon: page.icon, + content: prosemirrorJson, + textContent: jsonToText(prosemirrorJson), + ydoc: createYdocFromJson(prosemirrorJson), + position: page.id === rootPage.id ? nextPosition : page.position, + spaceId: spaceId, + workspaceId: page.workspaceId, + creatorId: authUser.id, + lastUpdatedById: authUser.id, + parentPageId: page.parentPageId + ? pageMap.get(page.parentPageId).newPageId + : null, + }; + }), + ); + + await this.db.insertInto('pages').values(insertablePages).execute(); + }); + } + async movePage(dto: MovePageDto, movedPage: Page) { // validate position value by attempting to generate a key try {