From de7982fe301dfc162ad7ad128ab506882b73ab82 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 30 Apr 2025 14:43:16 +0100 Subject: [PATCH] feat: copy page to different space (#1118) * Add copy page to space endpoint * copy storage function * copy function * feat: copy attachments too * Copy page - WIP * fix type * sync * cleanup --- .../public/locales/en-US/translation.json | 5 +- .../page/components/copy-page-modal.tsx | 105 +++++++++++ .../components/header/page-header-menu.tsx | 8 +- .../page/components/move-page-modal.tsx | 8 +- .../features/page/services/page-service.ts | 6 + .../page/tree/components/space-tree.tsx | 24 +++ .../src/features/page/types/page.types.ts | 7 +- .../src/common/helpers/prosemirror/utils.ts | 25 ++- .../server/src/core/page/dto/copy-page.dto.ts | 24 +++ .../server/src/core/page/dto/move-page.dto.ts | 10 +- apps/server/src/core/page/page.controller.ts | 31 ++++ apps/server/src/core/page/page.module.ts | 2 + .../src/core/page/services/page.service.ts | 166 +++++++++++++++++- .../storage/drivers/local.driver.ts | 10 ++ .../integrations/storage/drivers/s3.driver.ts | 17 ++ .../interfaces/storage-driver.interface.ts | 2 + .../integrations/storage/storage.service.ts | 5 + 17 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 apps/client/src/features/page/components/copy-page-modal.tsx create mode 100644 apps/server/src/core/page/dto/copy-page.dto.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 0746ed15..127730d5 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -383,5 +383,8 @@ "Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here", "Share deleted successfully": "Share deleted successfully", "Share not found": "Share not found", - "Failed to share page": "Failed to share page" + "Failed to share page": "Failed to share page", + "Copy page": "Copy page", + "Copy page to a different space.": "Copy page to a different space.", + "Page copied successfully": "Page copied successfully" } diff --git a/apps/client/src/features/page/components/copy-page-modal.tsx b/apps/client/src/features/page/components/copy-page-modal.tsx new file mode 100644 index 00000000..e639fbac --- /dev/null +++ b/apps/client/src/features/page/components/copy-page-modal.tsx @@ -0,0 +1,105 @@ +import { Modal, Button, Group, Text } from "@mantine/core"; +import { copyPageToSpace } from "@/features/page/services/page-service.ts"; +import { useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { queryClient } from "@/main.tsx"; +import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx"; +import { useNavigate } from "react-router-dom"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; + +interface CopyPageModalProps { + pageId: string; + currentSpaceSlug: string; + open: boolean; + onClose: () => void; +} + +export default function CopyPageModal({ + pageId, + currentSpaceSlug, + open, + onClose, +}: CopyPageModalProps) { + const { t } = useTranslation(); + const [targetSpace, setTargetSpace] = useState(null); + const navigate = useNavigate(); + + const handleCopy = async () => { + if (!targetSpace) return; + + try { + const copiedPage = await copyPageToSpace({ + pageId, + spaceId: targetSpace.id, + }); + queryClient.removeQueries({ + predicate: (item) => + ["pages", "sidebar-pages", "root-sidebar-pages"].includes( + item.queryKey[0] as string, + ), + }); + + const pageUrl = buildPageUrl( + copiedPage.space.slug, + copiedPage.slugId, + copiedPage.title, + ); + navigate(pageUrl); + notifications.show({ + message: t("Page copied successfully"), + }); + onClose(); + setTargetSpace(null); + } catch (err) { + notifications.show({ + message: err.response?.data.message || "An error occurred", + color: "red", + }); + console.log(err); + } + }; + + const handleChange = (space: ISpace) => { + setTargetSpace(space); + }; + + return ( + e.stopPropagation()} + > + + + + {t("Copy page")} + + + + + {t("Copy page to a different space.")} + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 9267ad9e..09305491 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -12,7 +12,7 @@ import { IconTrash, IconWifiOff, } from "@tabler/icons-react"; -import React, { useEffect } from "react"; +import React from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import { useAtom } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; @@ -35,7 +35,7 @@ import { import { formattedDate, timeAgo } from "@/lib/time.ts"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; -import ShareModal from '@/features/share/components/share-modal.tsx'; +import ShareModal from "@/features/share/components/share-modal.tsx"; interface PageHeaderMenuProps { readOnly?: boolean; @@ -59,7 +59,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { )} - + { const pageUrl = diff --git a/apps/client/src/features/page/components/move-page-modal.tsx b/apps/client/src/features/page/components/move-page-modal.tsx index 4788951b..74880084 100644 --- a/apps/client/src/features/page/components/move-page-modal.tsx +++ b/apps/client/src/features/page/components/move-page-modal.tsx @@ -46,6 +46,7 @@ export default function MovePageModal({ message: t("Page moved successfully"), }); onClose(); + setTargetSpace(null); } catch (err) { notifications.show({ message: err.response?.data.message || "An error occurred", @@ -53,7 +54,6 @@ export default function MovePageModal({ }); console.log(err); } - setTargetSpace(null); }; const handleChange = (space: ISpace) => { @@ -69,7 +69,7 @@ export default function MovePageModal({ yOffset="10vh" xOffset={0} mah={400} - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > @@ -78,7 +78,9 @@ export default function MovePageModal({ - {t("Move page to a different space.")} + + {t("Move page to a different space.")} + { await api.post("/pages/move-to-space", data); } +export async function copyPageToSpace(data: ICopyPageToSpace): Promise { + const req = await api.post("/pages/copy-to-space", data); + return req.data; +} + export async function getSidebarPages( params: SidebarPagesParams, ): Promise> { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 5a00f258..1df62678 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -15,6 +15,7 @@ import { IconArrowRight, IconChevronDown, IconChevronRight, + IconCopy, IconDotsVertical, IconFileDescription, IconFileExport, @@ -60,6 +61,7 @@ import ExportModal from "@/components/common/export-modal"; import MovePageModal from "../../components/move-page-modal.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; +import CopyPageModal from "../../components/copy-page-modal.tsx"; interface SpaceTreeProps { spaceId: string; @@ -448,6 +450,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { movePageModalOpened, { open: openMovePageModal, close: closeMoveSpaceModal }, ] = useDisclosure(false); + const [ + copyPageModalOpened, + { open: openCopyPageModal, close: closeCopySpaceModal }, + ] = useDisclosure(false); const handleCopyLink = () => { const pageUrl = @@ -511,6 +517,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { {t("Move")} + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + openCopyPageModal(); + }} + > + {t("Copy")} + + + + + 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/page.module.ts b/apps/server/src/core/page/page.module.ts index 93f8f719..fd336537 100644 --- a/apps/server/src/core/page/page.module.ts +++ b/apps/server/src/core/page/page.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { PageService } from './services/page.service'; import { PageController } from './page.controller'; import { PageHistoryService } from './services/page-history.service'; +import { StorageModule } from '../../integrations/storage/storage.module'; @Module({ controllers: [PageController], providers: [PageService, PageHistoryService], exports: [PageService, PageHistoryService], + imports: [StorageModule] }) 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 5e4553c6..41f3055e 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -1,12 +1,13 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; 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,13 +22,28 @@ 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, + getAttachmentIds, + getProsemirrorContent, + isAttachmentNode, + removeMarkTypeFromDoc, +} from '../../../common/helpers/prosemirror/utils'; +import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; +import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto'; +import { Node as PMNode } from '@tiptap/pm/model'; +import { StorageService } from '../../../integrations/storage/storage.service'; @Injectable() export class PageService { + private readonly logger = new Logger(PageService.name); + constructor( private pageRepo: PageRepo, private attachmentRepo: AttachmentRepo, @InjectKysely() private readonly db: KyselyDB, + private readonly storageService: StorageService, ) {} async findById( @@ -242,6 +258,154 @@ export class PageService { }); } + async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) { + //TODO: + // i. maintain internal links within copied pages + + 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 attachmentMap = new Map(); + + const insertablePages: InsertablePage[] = await Promise.all( + pages.map(async (page) => { + const pageContent = getProsemirrorContent(page.content); + const pageFromMap = pageMap.get(page.id); + + const doc = jsonToNode(pageContent); + const prosemirrorDoc = removeMarkTypeFromDoc(doc, 'comment'); + + const attachmentIds = getAttachmentIds(prosemirrorDoc.toJSON()); + + if (attachmentIds.length > 0) { + attachmentIds.forEach((attachmentId: string) => { + const newPageId = pageFromMap.newPageId; + const newAttachmentId = uuid7(); + attachmentMap.set(attachmentId, { + newPageId: newPageId, + oldPageId: page.id, + oldAttachmentId: attachmentId, + newAttachmentId: newAttachmentId, + }); + + prosemirrorDoc.descendants((node: PMNode) => { + if (isAttachmentNode(node.type.name)) { + if (node.attrs.attachmentId === attachmentId) { + //@ts-ignore + node.attrs.attachmentId = newAttachmentId; + + if (node.attrs.src) { + //@ts-ignore + node.attrs.src = node.attrs.src.replace( + attachmentId, + newAttachmentId, + ); + } + if (node.attrs.src) { + //@ts-ignore + node.attrs.src = node.attrs.src.replace( + attachmentId, + newAttachmentId, + ); + } + } + } + }); + }); + } + + const prosemirrorJson = prosemirrorDoc.toJSON(); + + return { + id: pageFromMap.newPageId, + slugId: pageFromMap.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(); + + //TODO: best to handle this in a queue + const attachmentsIds = Array.from(attachmentMap.keys()); + if (attachmentsIds.length > 0) { + const attachments = await this.db + .selectFrom('attachments') + .selectAll() + .where('id', 'in', attachmentsIds) + .where('workspaceId', '=', rootPage.workspaceId) + .execute(); + + for (const attachment of attachments) { + try { + const pageAttachment = attachmentMap.get(attachment.id); + + // make sure the copied attachment belongs to the page it was copied from + if (attachment.pageId !== pageAttachment.oldPageId) { + continue; + } + + const newAttachmentId = pageAttachment.newAttachmentId; + + const newPageId = pageAttachment.newPageId; + + const newPathFile = attachment.filePath.replace( + attachment.id, + newAttachmentId, + ); + await this.storageService.copy(attachment.filePath, newPathFile); + await this.db + .insertInto('attachments') + .values({ + id: newAttachmentId, + type: attachment.type, + filePath: newPathFile, + fileName: attachment.fileName, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType, + fileExt: attachment.fileExt, + creatorId: attachment.creatorId, + workspaceId: attachment.workspaceId, + pageId: newPageId, + spaceId: spaceId, + }) + .execute(); + } catch (err) { + this.logger.log(err); + } + } + } + + const newPageId = pageMap.get(rootPage.id).newPageId; + return await this.pageRepo.findById(newPageId, { + includeSpace: true, + }); + } + async movePage(dto: MovePageDto, movedPage: Page) { // validate position value by attempting to generate a key try { diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index f2553733..e3da7700 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -25,6 +25,16 @@ export class LocalDriver implements StorageDriver { } } + async copy(fromFilePath: string, toFilePath: string): Promise { + try { + if (await this.exists(fromFilePath)) { + await fs.copy(fromFilePath, toFilePath); + } + } catch (err) { + throw new Error(`Failed to copy file: ${(err as Error).message}`); + } + } + async read(filePath: string): Promise { try { return await fs.readFile(this._fullPath(filePath)); diff --git a/apps/server/src/integrations/storage/drivers/s3.driver.ts b/apps/server/src/integrations/storage/drivers/s3.driver.ts index 78f7548c..41feb365 100644 --- a/apps/server/src/integrations/storage/drivers/s3.driver.ts +++ b/apps/server/src/integrations/storage/drivers/s3.driver.ts @@ -1,5 +1,6 @@ import { S3StorageConfig, StorageDriver, StorageOption } from '../interfaces'; import { + CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, @@ -39,6 +40,22 @@ export class S3Driver implements StorageDriver { } } + async copy(fromFilePath: string, toFilePath: string): Promise { + try { + if (await this.exists(fromFilePath)) { + await this.s3Client.send( + new CopyObjectCommand({ + Bucket: this.config.bucket, + CopySource: `${this.config.bucket}/${fromFilePath}`, + Key: toFilePath, + }), + ); + } + } catch (err) { + throw new Error(`Failed to copy file: ${(err as Error).message}`); + } + } + async read(filePath: string): Promise { try { const command = new GetObjectCommand({ diff --git a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts index 419587f4..6f18ff45 100644 --- a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts +++ b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts @@ -1,6 +1,8 @@ export interface StorageDriver { upload(filePath: string, file: Buffer): Promise; + copy(fromFilePath: string, toFilePath: string): Promise; + read(filePath: string): Promise; exists(filePath: string): Promise; diff --git a/apps/server/src/integrations/storage/storage.service.ts b/apps/server/src/integrations/storage/storage.service.ts index acbd2188..fe4c1cb5 100644 --- a/apps/server/src/integrations/storage/storage.service.ts +++ b/apps/server/src/integrations/storage/storage.service.ts @@ -14,6 +14,11 @@ export class StorageService { this.logger.debug(`File uploaded successfully. Path: ${filePath}`); } + async copy(fromFilePath: string, toFilePath: string) { + await this.storageDriver.copy(fromFilePath, toFilePath); + this.logger.debug(`File copied successfully. Path: ${toFilePath}`); + } + async read(filePath: string): Promise { return this.storageDriver.read(filePath); }