diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 769ff61a..6c2289d8 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -222,7 +222,9 @@ "Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.", "Invite link": "Invite link", "Copy": "Copy", + "Copy to space": "Copy to space", "Copied": "Copied", + "Duplicate": "Duplicate", "Select a user": "Select a user", "Select a group": "Select a group", "Export all pages and attachments in this space.": "Export all pages and attachments in this space.", @@ -390,6 +392,7 @@ "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully", + "Page duplicated successfully": "Page duplicated successfully", "Find": "Find", "Not found": "Not found", "Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)", diff --git a/apps/client/src/features/page/components/copy-page-modal.tsx b/apps/client/src/features/page/components/copy-page-modal.tsx index e639fbac..4745f731 100644 --- a/apps/client/src/features/page/components/copy-page-modal.tsx +++ b/apps/client/src/features/page/components/copy-page-modal.tsx @@ -1,5 +1,5 @@ import { Modal, Button, Group, Text } from "@mantine/core"; -import { copyPageToSpace } from "@/features/page/services/page-service.ts"; +import { duplicatePage } from "@/features/page/services/page-service.ts"; import { useState } from "react"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; @@ -30,7 +30,7 @@ export default function CopyPageModal({ if (!targetSpace) return; try { - const copiedPage = await copyPageToSpace({ + const copiedPage = await duplicatePage({ pageId, spaceId: targetSpace.id, }); diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index a8e3d256..ad2be4f7 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise { 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); +export async function duplicatePage(data: ICopyPageToSpace): Promise { + const req = await api.post("/pages/duplicate", data); return req.data; } 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 26f07a7b..dad5f1e4 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -1,4 +1,10 @@ -import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; +import { + NodeApi, + NodeRendererProps, + Tree, + TreeApi, + SimpleTree, +} from "react-arborist"; import { atom, useAtom } from "jotai"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { @@ -66,6 +72,7 @@ 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"; +import { duplicatePage } from "../../services/page-service.ts"; interface SpaceTreeProps { spaceId: string; @@ -396,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { {node.data.name || t("untitled")}
- + {!tree.props.disableEdit && ( ; treeApi: TreeApi; + spaceId: string; } -function NodeMenu({ node, treeApi }: NodeMenuProps) { +function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { const { t } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const { spaceSlug } = useParams(); const { openDeleteModal } = useDeletePageModal(); + const [data, setData] = useAtom(treeDataAtom); + const emit = useQueryEmit(); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ @@ -474,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { notifications.show({ message: t("Link copied") }); }; + const handleDuplicatePage = async () => { + try { + const duplicatedPage = await duplicatePage({ + pageId: node.id, + }); + + // Find the index of the current node + const parentId = + node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__" + ? null + : node.parent?.id; + const siblings = parentId ? node.parent.children : treeApi?.props.data; + const currentIndex = + siblings?.findIndex((sibling) => sibling.id === node.id) || 0; + const newIndex = currentIndex + 1; + + // Add the duplicated page to the tree + const treeNodeData: SpaceTreeNode = { + id: duplicatedPage.id, + slugId: duplicatedPage.slugId, + name: duplicatedPage.title, + position: duplicatedPage.position, + spaceId: duplicatedPage.spaceId, + parentPageId: duplicatedPage.parentPageId, + icon: duplicatedPage.icon, + hasChildren: duplicatedPage.hasChildren, + children: [], + }; + + // Update local tree + const simpleTree = new SimpleTree(data); + simpleTree.create({ + parentId, + index: newIndex, + data: treeNodeData, + }); + setData(simpleTree.data); + + // Emit socket event + setTimeout(() => { + emit({ + operation: "addTreeNode", + spaceId: spaceId, + payload: { + parentId, + index: newIndex, + data: treeNodeData, + }, + }); + }, 50); + + notifications.show({ + message: t("Page duplicated successfully"), + }); + } catch (err) { + notifications.show({ + message: err.response?.data.message || "An error occurred", + color: "red", + }); + } + }; + return ( <> @@ -518,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { {!(treeApi.props.disableEdit as boolean) && ( <> + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicatePage(); + }} + > + {t("Duplicate")} + + } onClick={(e) => { @@ -537,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { openCopyPageModal(); }} > - {t("Copy")} + {t("Copy to space")} diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 19dc18fd..f97c4514 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -49,7 +49,7 @@ export interface IMovePageToSpace { export interface ICopyPageToSpace { pageId: string; - spaceId: string; + spaceId?: string; } export interface SidebarPagesParams { diff --git a/apps/server/src/core/page/dto/copy-page.dto.ts b/apps/server/src/core/page/dto/duplicate-page.dto.ts similarity index 68% rename from apps/server/src/core/page/dto/copy-page.dto.ts rename to apps/server/src/core/page/dto/duplicate-page.dto.ts index 09de3083..395ad9a3 100644 --- a/apps/server/src/core/page/dto/copy-page.dto.ts +++ b/apps/server/src/core/page/dto/duplicate-page.dto.ts @@ -1,13 +1,13 @@ -import { IsString, IsNotEmpty } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; -export class CopyPageToSpaceDto { +export class DuplicatePageDto { @IsNotEmpty() @IsString() pageId: string; - @IsNotEmpty() + @IsOptional() @IsString() - spaceId: string; + spaceId?: string; } export type CopyPageMapEntry = { diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 145c5313..565ecd1e 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -28,7 +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'; +import { DuplicatePageDto } from './dto/duplicate-page.dto'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -242,33 +242,41 @@ export class PageController { } @HttpCode(HttpStatus.OK) - @Post('copy-to-space') - async copyPageToSpace( - @Body() dto: CopyPageToSpaceDto, - @AuthUser() user: User, - ) { + @Post('duplicate') + async duplicatePage(@Body() dto: DuplicatePageDto, @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'); + + // If spaceId is provided, it's a copy to different space + if (dto.spaceId) { + 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.duplicatePage(copiedPage, dto.spaceId, user); + } else { + // If no spaceId, it's a duplicate in same space + const ability = await this.spaceAbility.createForUser( + user, + copiedPage.spaceId, + ); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pageService.duplicatePage(copiedPage, undefined, user); } - - 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) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 41f3055e..4f96e0ca 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -31,7 +31,10 @@ import { removeMarkTypeFromDoc, } from '../../../common/helpers/prosemirror/utils'; import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; -import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto'; +import { + CopyPageMapEntry, + ICopyPageAttachment, +} from '../dto/duplicate-page.dto'; import { Node as PMNode } from '@tiptap/pm/model'; import { StorageService } from '../../../integrations/storage/storage.service'; @@ -258,11 +261,52 @@ export class PageService { }); } - async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) { - //TODO: - // i. maintain internal links within copied pages + async duplicatePage( + rootPage: Page, + targetSpaceId: string | undefined, + authUser: User, + ) { + const spaceId = targetSpaceId || rootPage.spaceId; + const isDuplicateInSameSpace = + !targetSpaceId || targetSpaceId === rootPage.spaceId; - const nextPosition = await this.nextPagePosition(spaceId); + let nextPosition: string; + + if (isDuplicateInSameSpace) { + // For duplicate in same space, position right after the original page + let siblingQuery = this.db + .selectFrom('pages') + .select(['position']) + .where('spaceId', '=', rootPage.spaceId) + .where('position', '>', rootPage.position); + + if (rootPage.parentPageId) { + siblingQuery = siblingQuery.where( + 'parentPageId', + '=', + rootPage.parentPageId, + ); + } else { + siblingQuery = siblingQuery.where('parentPageId', 'is', null); + } + + const nextSibling = await siblingQuery + .orderBy('position', 'asc') + .limit(1) + .executeTakeFirst(); + + if (nextSibling) { + nextPosition = generateJitteredKeyBetween( + rootPage.position, + nextSibling.position, + ); + } else { + nextPosition = generateJitteredKeyBetween(rootPage.position, null); + } + } else { + // For copy to different space, position at the end + nextPosition = await this.nextPagePosition(spaceId); + } const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, { includeContent: true, @@ -326,12 +370,38 @@ export class PageService { }); } + // Update internal page links in mention nodes + prosemirrorDoc.descendants((node: PMNode) => { + if ( + node.type.name === 'mention' && + node.attrs.entityType === 'page' + ) { + const referencedPageId = node.attrs.entityId; + + // Check if the referenced page is within the pages being copied + if (referencedPageId && pageMap.has(referencedPageId)) { + const mappedPage = pageMap.get(referencedPageId); + //@ts-ignore + node.attrs.entityId = mappedPage.newPageId; + //@ts-ignore + node.attrs.slugId = mappedPage.newSlugId; + } + } + }); + const prosemirrorJson = prosemirrorDoc.toJSON(); + // Add "Copy of " prefix to the root page title only for duplicates in same space + let title = page.title; + if (isDuplicateInSameSpace && page.id === rootPage.id) { + const originalTitle = page.title || 'Untitled'; + title = `Copy of ${originalTitle}`; + } + return { id: pageFromMap.newPageId, slugId: pageFromMap.newSlugId, - title: page.title, + title: title, icon: page.icon, content: prosemirrorJson, textContent: jsonToText(prosemirrorJson), @@ -401,9 +471,16 @@ export class PageService { } const newPageId = pageMap.get(rootPage.id).newPageId; - return await this.pageRepo.findById(newPageId, { + const duplicatedPage = await this.pageRepo.findById(newPageId, { includeSpace: true, }); + + const hasChildren = pages.length > 1; + + return { + ...duplicatedPage, + hasChildren, + }; } async movePage(dto: MovePageDto, movedPage: Page) {