From 73c2555e27aee0d194bd80d65fa86d809c21c6f8 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 27 Apr 2025 22:10:36 +0100 Subject: [PATCH] feat: copy attachments too --- .../server/src/core/page/dto/copy-page.dto.ts | 7 + apps/server/src/core/page/page.module.ts | 2 + .../src/core/page/services/page.service.ts | 195 +++++++++++++----- 3 files changed, 156 insertions(+), 48 deletions(-) diff --git a/apps/server/src/core/page/dto/copy-page.dto.ts b/apps/server/src/core/page/dto/copy-page.dto.ts index 7fec56f6..09de3083 100644 --- a/apps/server/src/core/page/dto/copy-page.dto.ts +++ b/apps/server/src/core/page/dto/copy-page.dto.ts @@ -15,3 +15,10 @@ export type CopyPageMapEntry = { newSlugId: string; oldSlugId: string; }; + +export type ICopyPageAttachment = { + newPageId: string, + oldPageId: string, + oldAttachmentId: string, + newAttachmentId: string, +}; 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 8227a1fe..f3c759b8 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { CreatePageDto } from '../dto/create-page.dto'; @@ -24,18 +25,25 @@ 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 } from '../dto/copy-page.dto'; +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( @@ -252,56 +260,147 @@ 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 + // i. maintain internal links within copied pages - await executeTx(this.db, async (trx) => { - const nextPosition = await this.nextPagePosition(spaceId); + 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(); + 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) { + return; + } + + 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); + } + } } async movePage(dto: MovePageDto, movedPage: Page) {